@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
package/package.json CHANGED
@@ -2,25 +2,34 @@
2
2
  "name": "@xpufx/paseo-top",
3
3
  "private": false,
4
4
  "license": "MIT",
5
- "version": "0.4.3",
5
+ "version": "0.4.5",
6
6
  "scripts": {
7
7
  "stamp": "node -e \"import('./server/vendor/paseo-plugin-helper/version.ts').then(m => m.stampVersion({ targetFile: 'shared/version.ts' }))\"",
8
8
  "typecheck": "tsc --noEmit",
9
- "test": "npm run typecheck && npx tsx --test server/telemetry.test.ts client/pill-labels.test.ts client/turn-counter.test.ts client/telemetry-copy.test.ts client/multi-host.test.ts"
9
+ "test": "npm run typecheck && vitest run --config client/vitest.config.ts"
10
+ },
11
+ "dependencies": {
12
+ "@xpufx/paseo-plugin-ui": "^0.1.0"
10
13
  },
11
14
  "devDependencies": {
15
+ "paseo-plugin-ui-testing": "*",
12
16
  "@getpaseo/plugin": "^0.9.0",
13
17
  "@tanstack/react-query": "^5.90.11",
14
18
  "@types/react": "^19.0.10",
19
+ "@types/react-test-renderer": "^19.3.0",
15
20
  "react": "19.1.0",
16
21
  "react-native": "0.81.5",
22
+ "react-test-renderer": "^19.1.0",
17
23
  "typescript": "^5.9.3",
24
+ "vitest": "^3.0.0",
18
25
  "zod": "^4.4.3"
19
26
  },
20
27
  "files": [
21
28
  "paseo-plugin.json",
22
29
  "README.md",
30
+ "OVERVIEW.md",
23
31
  "LICENSE",
32
+ "screenshots",
24
33
  "index.client.tsx",
25
34
  "index.server.ts",
26
35
  "client",
@@ -30,6 +39,9 @@
30
39
  "!**/*.test.ts",
31
40
  "!**/*.test.tsx",
32
41
  "!**/*.test.mjs",
42
+ "!client/test-utils",
43
+ "!server/__node-test-shim.ts",
44
+ "!client/vitest.config.ts",
33
45
  "!**/.DS_Store"
34
46
  ],
35
47
  "repository": {
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
+
@@ -16,3 +16,7 @@ export * from "./custom-pills";
16
16
  export * from "./rpc-guard";
17
17
  export * from "./agent";
18
18
  export * from "./workspace-beacon";
19
+ export * from "./tickets";
20
+ export * from "./storage-guard";
21
+ export * from "./telemetry";
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
 
@@ -4,6 +4,7 @@ export interface McpStdioInjectionConfig {
4
4
  args?: string[];
5
5
  env?: Record<string, string>;
6
6
  alwaysLoad?: boolean;
7
+ instructions?: string;
7
8
  }
8
9
 
9
10
  export interface McpHttpInjectionConfig {
@@ -11,6 +12,7 @@ export interface McpHttpInjectionConfig {
11
12
  url: string;
12
13
  headers?: Record<string, string>;
13
14
  alwaysLoad?: boolean;
15
+ instructions?: string;
14
16
  }
15
17
 
16
18
  export interface McpSseInjectionConfig {
@@ -18,6 +20,7 @@ export interface McpSseInjectionConfig {
18
20
  url: string;
19
21
  headers?: Record<string, string>;
20
22
  alwaysLoad?: boolean;
23
+ instructions?: string;
21
24
  }
22
25
 
23
26
  export type McpInjectionConfig =
@@ -71,15 +74,30 @@ export function registerMcpInjection(
71
74
  const { serverName, config, filter } = options;
72
75
  return server.before("agent.create", ({ request }: { request: AgentCreateInjectionRequest }) => {
73
76
  if (filter && !filter({ request })) return;
74
- return {
77
+ const injectedConfig = { ...config };
78
+ if (injectedConfig.instructions === "") {
79
+ delete injectedConfig.instructions;
80
+ }
81
+ const composed: AgentCreateInjectionRequest = {
75
82
  ...request,
76
83
  config: {
77
84
  ...request.config,
78
85
  mcpServers: {
79
86
  ...(request.config.mcpServers ?? {}),
80
- [serverName]: config,
87
+ [serverName]: injectedConfig,
81
88
  },
82
89
  },
83
90
  };
91
+ // Opt-in per server: only a config that sets `instructions` gets text
92
+ // composed into the agent. Absent means byte-identical passthrough.
93
+ if (typeof config.instructions !== "string" || config.instructions === "") return composed;
94
+ const base = typeof request.config.systemPrompt === "string" ? request.config.systemPrompt : "";
95
+ return {
96
+ ...composed,
97
+ config: {
98
+ ...composed.config,
99
+ systemPrompt: base.trim().length > 0 ? `${base}\n\n${config.instructions}` : config.instructions,
100
+ },
101
+ };
84
102
  });
85
103
  }
@@ -26,15 +26,54 @@ export interface ListPluginsOptions {
26
26
  forceRefresh?: boolean;
27
27
  }
28
28
 
29
+ /** Normalized presence record returned to plugin code by `listPlugins(context)`. */
30
+ export interface PluginPresence {
31
+ id: string;
32
+ status: string;
33
+ enabled: boolean;
34
+ }
35
+
36
+ /**
37
+ * Minimal structural view of the daemon plugin surface a plugin may reach
38
+ * through `context.paseo`. Current SDK `PaseoApi` exposes no plugins actions,
39
+ * so this stays optional and duck-typed; when absent the helper falls back to
40
+ * the daemon's existing `paseo plugin ls --json` query.
41
+ */
42
+ export interface PaseoPluginsSurface {
43
+ list(): Promise<unknown>;
44
+ }
45
+
46
+ /**
47
+ * Handler/hook context shape plugin server code passes to the presence API.
48
+ * `paseo` stays `unknown` so the real `PluginHandlerContext` (whose `paseo` is
49
+ * the SDK `PaseoApi`) is assignable even though current SDK builds expose no
50
+ * plugins actions; the surface is duck-typed at call time.
51
+ */
52
+ export interface PluginRegistryContext {
53
+ paseo?: unknown;
54
+ }
55
+
56
+ export interface PluginQueryOptions {
57
+ cacheTtlMs?: number;
58
+ forceRefresh?: boolean;
59
+ }
60
+
61
+ interface PresenceCache {
62
+ at: number;
63
+ plugins: PluginPresence[];
64
+ }
65
+
29
66
  let cachedPlugins: PaseoPluginInfo[] | null = null;
30
67
  let lastFetchTime = 0;
68
+ let cachedPresence: PresenceCache | null = null;
31
69
 
32
70
  /**
33
- * Clears the in-memory plugin list cache.
71
+ * Clears the in-memory plugin list and presence caches.
34
72
  */
35
73
  export function clearPluginCache(): void {
36
74
  cachedPlugins = null;
37
75
  lastFetchTime = 0;
76
+ cachedPresence = null;
38
77
  }
39
78
 
40
79
  /**
@@ -68,10 +107,30 @@ function readConfigPluginsFallback(): PaseoPluginInfo[] {
68
107
  }
69
108
 
70
109
  /**
71
- * Lists plugins from the Paseo daemon, with optional filtering and TTL caching.
72
- * Primary mechanism uses 'paseo plugin ls --json', with fallback to ~/.paseo/config.json.
110
+ * Queries the daemon's authoritative plugin list through the CLI RPC that the
111
+ * daemon already serves ('paseo plugin ls --json'). Returns null when the daemon
112
+ * is unreachable or answers with a non-array payload, so callers can choose
113
+ * their own fallback instead of parsing filesystem state.
114
+ */
115
+ async function fetchDaemonPluginInfo(): Promise<PaseoPluginInfo[] | null> {
116
+ try {
117
+ const result = await safeSpawn("paseo", ["plugin", "ls", "--json"], { timeoutMs: 3000 });
118
+ if (result.code === 0 && result.stdout.trim()) {
119
+ const parsed: unknown = JSON.parse(result.stdout.trim());
120
+ return Array.isArray(parsed) ? (parsed as PaseoPluginInfo[]) : null;
121
+ }
122
+ } catch {
123
+ // Unreachable daemon / malformed payload: report absence, never throw.
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Lists full plugin metadata from the Paseo daemon, with optional filtering and
130
+ * TTL caching. Primary mechanism uses 'paseo plugin ls --json', with fallback to
131
+ * ~/.paseo/config.json.
73
132
  */
74
- export async function listPlugins(options: ListPluginsOptions = {}): Promise<PaseoPluginInfo[]> {
133
+ async function listPluginInfo(options: ListPluginsOptions = {}): Promise<PaseoPluginInfo[]> {
75
134
  const { filter = "all", cacheTtlMs = 5000, forceRefresh = false } = options;
76
135
  const now = Date.now();
77
136
 
@@ -79,23 +138,102 @@ export async function listPlugins(options: ListPluginsOptions = {}): Promise<Pas
79
138
  return applyFilter(cachedPlugins, filter);
80
139
  }
81
140
 
82
- let plugins: PaseoPluginInfo[] = [];
141
+ const fromDaemon = await fetchDaemonPluginInfo();
142
+ const plugins = fromDaemon ?? readConfigPluginsFallback();
83
143
 
84
- try {
85
- const result = await safeSpawn("paseo", ["plugin", "ls", "--json"], { timeoutMs: 3000 });
86
- if (result.code === 0 && result.stdout.trim()) {
87
- plugins = JSON.parse(result.stdout.trim());
88
- } else {
89
- plugins = readConfigPluginsFallback();
144
+ cachedPlugins = plugins;
145
+ lastFetchTime = now;
146
+
147
+ return applyFilter(plugins, filter);
148
+ }
149
+
150
+ function isRegistryContext(value: unknown): value is PluginRegistryContext {
151
+ return !!value && typeof value === "object" && "paseo" in (value as Record<string, unknown>);
152
+ }
153
+
154
+ /** Coerces a daemon plugin list (SDK shape) into normalized presence records. */
155
+ function normalizePresence(raw: unknown): PluginPresence[] {
156
+ if (!Array.isArray(raw)) return [];
157
+ const out: PluginPresence[] = [];
158
+ for (const entry of raw) {
159
+ if (!entry || typeof entry !== "object") continue;
160
+ const rec = entry as Record<string, unknown>;
161
+ const id = typeof rec.id === "string" ? rec.id : null;
162
+ if (!id) continue;
163
+ const enabled = rec.enabled !== false;
164
+ const status =
165
+ typeof rec.status === "string" ? rec.status : enabled ? "unknown" : "disabled";
166
+ out.push({ id, status, enabled });
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * Resolves plugin presence through the sanctioned daemon channel. Prefers a
173
+ * `context.paseo.plugins.list()` SDK surface when one exists; otherwise falls
174
+ * back to the daemon's existing plugin-list RPC. Any failure — no context, no
175
+ * surface, unreachable daemon, malformed payload — resolves to `[]`.
176
+ *
177
+ * Only the daemon-fallback result is cached (the CLI spawn is the real cost);
178
+ * a caller-supplied context surface is always consulted fresh so different
179
+ * contexts never observe each other's cached presence.
180
+ */
181
+ async function listPluginPresence(
182
+ context: PluginRegistryContext | null | undefined,
183
+ options: PluginQueryOptions = {},
184
+ ): Promise<PluginPresence[]> {
185
+ const { cacheTtlMs = 5000, forceRefresh = false } = options;
186
+ const now = Date.now();
187
+ const surface = readPluginsSurface(context?.paseo);
188
+
189
+ if (!surface) {
190
+ if (!forceRefresh && cachedPresence && now - cachedPresence.at < cacheTtlMs) {
191
+ return cachedPresence.plugins;
90
192
  }
193
+ const plugins = normalizePresence(await fetchDaemonPluginInfo());
194
+ cachedPresence = { at: now, plugins };
195
+ return plugins;
196
+ }
197
+
198
+ try {
199
+ return normalizePresence(await surface.list());
91
200
  } catch {
92
- plugins = readConfigPluginsFallback();
201
+ return [];
93
202
  }
203
+ }
94
204
 
95
- cachedPlugins = plugins;
96
- lastFetchTime = now;
205
+ /** Duck-types `context.paseo.plugins.list()` without assuming the SDK shape. */
206
+ function readPluginsSurface(paseo: unknown): PaseoPluginsSurface | null {
207
+ if (!paseo || typeof paseo !== "object") return null;
208
+ const plugins = (paseo as { plugins?: unknown }).plugins;
209
+ if (!plugins || typeof plugins !== "object") return null;
210
+ const list = (plugins as { list?: unknown }).list;
211
+ return typeof list === "function" ? (plugins as PaseoPluginsSurface) : null;
212
+ }
97
213
 
98
- return applyFilter(plugins, filter);
214
+ /**
215
+ * Lists plugins. Called with a plugin/handler `context`, returns normalized
216
+ * presence records `{ id, status, enabled }` resolved through the daemon (see
217
+ * `listPluginPresence`); absence tolerates to `[]`. Called without a context,
218
+ * returns the legacy full `PaseoPluginInfo[]` metadata list.
219
+ */
220
+ export function listPlugins(
221
+ context: PluginRegistryContext | null | undefined,
222
+ options?: PluginQueryOptions,
223
+ ): Promise<PluginPresence[]>;
224
+ export function listPlugins(options?: ListPluginsOptions): Promise<PaseoPluginInfo[]>;
225
+ export async function listPlugins(
226
+ contextOrOptions?: PluginRegistryContext | ListPluginsOptions | null,
227
+ maybeOptions: PluginQueryOptions = {},
228
+ ): Promise<PluginPresence[] | PaseoPluginInfo[]> {
229
+ // A registry context is an object carrying `paseo`, or an explicitly passed
230
+ // null context (the "caller has no context" case, which tolerates to []). A
231
+ // bare option object (no `paseo` key), or no argument at all, stays on the
232
+ // legacy full-metadata path.
233
+ if (isRegistryContext(contextOrOptions) || contextOrOptions === null) {
234
+ return listPluginPresence(contextOrOptions, maybeOptions);
235
+ }
236
+ return listPluginInfo(contextOrOptions ?? {});
99
237
  }
100
238
 
101
239
  function applyFilter(plugins: PaseoPluginInfo[], filter: PluginStatusFilter): PaseoPluginInfo[] {
@@ -128,13 +266,40 @@ export async function getPluginInfo(
128
266
 
129
267
  /**
130
268
  * Checks whether a plugin is installed in Paseo.
269
+ *
270
+ * Two forms:
271
+ * - `isPluginInstalled(pluginId, options?)` — legacy metadata query: true when
272
+ * the plugin appears in the full plugin list at all.
273
+ * - `isPluginInstalled(context, id, options?)` — presence query: true only when
274
+ * the plugin is usable (present, enabled, and status "running"). Anything else
275
+ * — absent, disabled, failed, unknown — resolves to false. A missing daemon
276
+ * surface tolerates to false, never throws.
131
277
  */
132
- export async function isPluginInstalled(
278
+ export function isPluginInstalled(
133
279
  pluginId: string,
134
- options?: { cacheTtlMs?: number; forceRefresh?: boolean },
280
+ options?: PluginQueryOptions,
281
+ ): Promise<boolean>;
282
+ export function isPluginInstalled(
283
+ context: PluginRegistryContext | null | undefined,
284
+ id: string,
285
+ options?: PluginQueryOptions,
286
+ ): Promise<boolean>;
287
+ export async function isPluginInstalled(
288
+ contextOrId: PluginRegistryContext | string | null | undefined,
289
+ idOrOptions?: string | PluginQueryOptions,
290
+ maybeOptions: PluginQueryOptions = {},
135
291
  ): Promise<boolean> {
136
- const info = await getPluginInfo(pluginId, options);
137
- return info !== null;
292
+ if (typeof contextOrId === "string") {
293
+ const info = await getPluginInfo(contextOrId, idOrOptions as PluginQueryOptions | undefined);
294
+ return info !== null;
295
+ }
296
+
297
+ const id = typeof idOrOptions === "string" ? idOrOptions : "";
298
+ if (!id) return false;
299
+
300
+ const plugins = await listPluginPresence(contextOrId, maybeOptions);
301
+ const match = plugins.find((p) => p.id === id);
302
+ return !!match && match.enabled && match.status === "running";
138
303
  }
139
304
 
140
305
  /**
@@ -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;