@xpufx/paseo-top 0.4.3 → 0.4.4

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 (33) hide show
  1. package/client/pill-labels.ts +30 -6
  2. package/client/pill.tsx +40 -61
  3. package/client/settings-events.ts +33 -0
  4. package/client/surface.tsx +6 -2
  5. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +12 -0
  6. package/client/vendor/paseo-plugin-helper/components/Button.tsx +22 -2
  7. package/client/vendor/paseo-plugin-helper/components/Card.tsx +32 -0
  8. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +17 -1
  9. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +14 -1
  10. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +142 -0
  11. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +16 -2
  12. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +7 -0
  13. package/client/vendor/paseo-plugin-helper/components/Select.tsx +131 -63
  14. package/client/vendor/paseo-plugin-helper/components/index.ts +1 -0
  15. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +4 -3
  16. package/client/vendor/paseo-plugin-helper/host.ts +12 -58
  17. package/client/vendor/paseo-plugin-helper/index.ts +1 -0
  18. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +17 -27
  19. package/client/vendor/paseo-plugin-helper/pill.tsx +68 -321
  20. package/client/vendor/paseo-plugin-helper/surface.tsx +62 -2
  21. package/client/vendor/paseo-plugin-helper/tickets.tsx +766 -0
  22. package/index.server.ts +7 -0
  23. package/package.json +11 -2
  24. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  25. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  26. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  27. package/server/vendor/paseo-plugin-helper/tickets.ts +1281 -0
  28. package/shared/helper-version.ts +20 -0
  29. package/shared/multi-host.ts +11 -4
  30. package/shared/resources.ts +1 -1
  31. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  32. package/shared/vendor/paseo-plugin-helper/tickets.ts +1010 -0
  33. package/shared/version.ts +1 -1
package/index.server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PluginServerContext } from "@getpaseo/plugin/server";
2
2
  import { guardRpcHandler } from "./server/vendor/paseo-plugin-helper/index";
3
+ import { registerPermissionAuditServer } from "permission-audit/server";
3
4
  import {
4
5
  getSystemResourcesRpc,
5
6
  getCustomPillsRpc,
@@ -74,6 +75,11 @@ export default function contribute(server: PluginServerContext) {
74
75
  server.handle(listCustomPillsRpc, handleListCustomPills);
75
76
  server.handle(runCustomPillModalCommandRpc, handleRunCustomPillModalCommand);
76
77
 
78
+ const { unsubscribe: unsubscribePermissionAudit } = registerPermissionAuditServer(
79
+ server as unknown as Parameters<typeof registerPermissionAuditServer>[0],
80
+ { logger: log },
81
+ );
82
+
77
83
  const turnStartTimes = new Map<string, number>();
78
84
  const turnGitBefore = new Map<string, { insertions: number; deletions: number; filesChanged: number }>();
79
85
  // Live turn per agent, set on turn_started and consumed by its terminal, used
@@ -283,6 +289,7 @@ export default function contribute(server: PluginServerContext) {
283
289
  lastCanceledAt.clear();
284
290
  lastCanceledUserMessages.clear();
285
291
  customPillPoller.stop();
292
+ unsubscribePermissionAudit();
286
293
  unsubscribeTurnStarted();
287
294
  unsubscribeAgentCreated();
288
295
  unsubscribeTurnEnded();
package/package.json CHANGED
@@ -2,19 +2,25 @@
2
2
  "name": "@xpufx/paseo-top",
3
3
  "private": false,
4
4
  "license": "MIT",
5
- "version": "0.4.3",
5
+ "version": "0.4.4",
6
+ "dependencies": {
7
+ "permission-audit": "*"
8
+ },
6
9
  "scripts": {
7
10
  "stamp": "node -e \"import('./server/vendor/paseo-plugin-helper/version.ts').then(m => m.stampVersion({ targetFile: 'shared/version.ts' }))\"",
8
11
  "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"
12
+ "test": "npm run typecheck && vitest run --config client/vitest.config.ts"
10
13
  },
11
14
  "devDependencies": {
12
15
  "@getpaseo/plugin": "^0.9.0",
13
16
  "@tanstack/react-query": "^5.90.11",
14
17
  "@types/react": "^19.0.10",
18
+ "@types/react-test-renderer": "^19.3.0",
15
19
  "react": "19.1.0",
16
20
  "react-native": "0.81.5",
21
+ "react-test-renderer": "^19.1.0",
17
22
  "typescript": "^5.9.3",
23
+ "vitest": "^3.0.0",
18
24
  "zod": "^4.4.3"
19
25
  },
20
26
  "files": [
@@ -30,6 +36,9 @@
30
36
  "!**/*.test.ts",
31
37
  "!**/*.test.tsx",
32
38
  "!**/*.test.mjs",
39
+ "!client/test-utils",
40
+ "!server/__node-test-shim.ts",
41
+ "!client/vitest.config.ts",
33
42
  "!**/.DS_Store"
34
43
  ],
35
44
  "repository": {
@@ -16,3 +16,5 @@ 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
+
@@ -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
  /**