@xpufx/paseo-top 0.4.0

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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/client/pill-labels.ts +480 -0
  4. package/client/pill.tsx +2461 -0
  5. package/client/resources-query.ts +68 -0
  6. package/client/settings-ui.tsx +50 -0
  7. package/client/surface.tsx +364 -0
  8. package/client/telemetry-copy.ts +105 -0
  9. package/client/telemetry.tsx +486 -0
  10. package/client/turn-counter.ts +151 -0
  11. package/client/turn-panel.tsx +182 -0
  12. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  13. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  14. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  15. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  16. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  17. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  18. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  19. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  20. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  21. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  22. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  23. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  24. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  25. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  26. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  27. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  28. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  29. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  30. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  31. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  32. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  33. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  34. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  35. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  36. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  37. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  38. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  39. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  40. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  41. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  42. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  43. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  44. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  45. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  46. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  47. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  48. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  49. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  50. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  51. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  52. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  53. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  54. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  55. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  56. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  57. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  58. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  59. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  60. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  61. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  62. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  63. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  64. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  65. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  66. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  67. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  68. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  69. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  70. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  71. package/examples/pills/battery.jsonc +23 -0
  72. package/examples/pills/disk-usage.jsonc +18 -0
  73. package/examples/pills/docker-containers.jsonc +22 -0
  74. package/examples/pills/git-dirty.jsonc +22 -0
  75. package/examples/pills/gpu-nvidia.jsonc +22 -0
  76. package/package.json +37 -0
  77. package/paseo-plugin.json +4 -0
  78. package/server/resources.ts +784 -0
  79. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  80. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  81. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  82. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  83. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  84. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  85. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  86. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  87. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  88. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  89. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  90. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  91. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  92. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  93. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  94. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  95. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  96. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  97. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  98. package/shared/resources.ts +686 -0
  99. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  100. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  101. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  102. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  103. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  104. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  105. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  106. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  107. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  108. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  109. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  110. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  111. package/shared/version.ts +2 -0
@@ -0,0 +1,784 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import child_process from "node:child_process";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(child_process.execFile);
7
+ import os from "node:os";
8
+ import {
9
+ getSystemMetrics,
10
+ createPluginLogger,
11
+ PluginStorage,
12
+ isPluginInstalled,
13
+ isPluginRunning,
14
+ CustomPillPoller,
15
+ discoverCustomPillConfigs,
16
+ } from "paseo-plugin-helper/server";
17
+ import {
18
+ type SystemResources,
19
+ topSettingsContract,
20
+ type TopSettings,
21
+ type ResourceField,
22
+ type McpResourceStatus,
23
+ type McpStatusSnapshot,
24
+ type CustomPillState,
25
+ type CustomPillDefinition,
26
+ type TopTimelineTelemetryData,
27
+ type LiveUsage,
28
+ customPillEffectiveEnabled,
29
+ } from "../shared/resources";
30
+ import { PLUGIN_VERSION } from "../shared/version";
31
+
32
+ export const log = createPluginLogger("top", { version: PLUGIN_VERSION });
33
+
34
+ log.info("Initialized host system resources monitor", { version: PLUGIN_VERSION });
35
+
36
+ const CUSTOM_PILLS_DIR = path.join(os.homedir(), ".paseo", "top", "pills");
37
+
38
+ try {
39
+ if (!fs.existsSync(CUSTOM_PILLS_DIR)) {
40
+ fs.mkdirSync(CUSTOM_PILLS_DIR, { recursive: true });
41
+ log.info("Created custom pills directory", { path: CUSTOM_PILLS_DIR });
42
+ }
43
+
44
+ const existingFiles = fs.readdirSync(CUSTOM_PILLS_DIR);
45
+ if (existingFiles.length === 0) {
46
+ const seedTemplates: Record<string, string> = {
47
+ "disk-usage.jsonc.example": `// Free disk space on root partition
48
+ // Rename to disk-usage.jsonc to enable
49
+ {
50
+ "id": "root-disk",
51
+ "title": "Disk",
52
+ "compactTitle": "Disk",
53
+ "icon": "HardDrive",
54
+ "command": "df -h / | awk 'NR==2 {print $4}'",
55
+ "suffix": " free",
56
+ "intervalMs": 10000,
57
+ "timeoutMs": 3000,
58
+ "modal": {
59
+ "title": "Filesystem Disk Space",
60
+ "description": "Output of df -h across mounted filesystems",
61
+ "command": "df -h",
62
+ "preformatted": true
63
+ }
64
+ }
65
+ `,
66
+ "docker-containers.jsonc.example": `// Count of running Docker containers
67
+ // Rename to docker-containers.jsonc to enable
68
+ {
69
+ "id": "docker-containers",
70
+ "title": "Docker",
71
+ "compactTitle": "Docker",
72
+ "icon": "Box",
73
+ "command": "docker ps -q 2>/dev/null | wc -l",
74
+ "suffix": " running",
75
+ "intervalMs": 5000,
76
+ "timeoutMs": 3000,
77
+ "thresholds": {
78
+ "warning": 10,
79
+ "danger": 20
80
+ },
81
+ "modal": {
82
+ "title": "Running Docker Containers",
83
+ "description": "Live container status via docker ps",
84
+ "command": "docker ps --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'",
85
+ "preformatted": true
86
+ }
87
+ }
88
+ `,
89
+ "gpu-nvidia.jsonc.example": `// NVIDIA GPU Utilization
90
+ // Rename to gpu-nvidia.jsonc to enable
91
+ {
92
+ "id": "gpu-util",
93
+ "title": "GPU",
94
+ "compactTitle": "GPU",
95
+ "icon": "Cpu",
96
+ "command": "nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits",
97
+ "suffix": "%",
98
+ "intervalMs": 3000,
99
+ "timeoutMs": 2000,
100
+ "thresholds": {
101
+ "warning": 70,
102
+ "danger": 90
103
+ },
104
+ "modal": {
105
+ "title": "GPU Vitals & Memory",
106
+ "description": "Live hardware telemetry from nvidia-smi",
107
+ "command": "nvidia-smi",
108
+ "preformatted": true
109
+ }
110
+ }
111
+ `,
112
+ };
113
+
114
+ for (const [filename, content] of Object.entries(seedTemplates)) {
115
+ fs.writeFileSync(path.join(CUSTOM_PILLS_DIR, filename), content, "utf8");
116
+ }
117
+ log.info("Seeded sample custom pill templates", { count: Object.keys(seedTemplates).length });
118
+ }
119
+ } catch (err) {
120
+ log.warn("Failed to ensure custom pills directory", {
121
+ error: err instanceof Error ? err.message : String(err),
122
+ });
123
+ }
124
+
125
+ export const customPillPoller = new CustomPillPoller({
126
+ configDir: CUSTOM_PILLS_DIR,
127
+ logger: log,
128
+ });
129
+
130
+ const effectiveEnabled = new Set<string>();
131
+
132
+ export async function refreshCustomPillConfigs(): Promise<void> {
133
+ try {
134
+ const settings = await settingsStorage.readAsync();
135
+ const overrides = settings.customPillEnabled ?? {};
136
+ const discovered = await discoverCustomPillConfigs(CUSTOM_PILLS_DIR, log);
137
+ const masterEnabled = settings.showCustomPills ?? true;
138
+ const effective = discovered.map((pill) => {
139
+ const enabled = customPillEffectiveEnabled(masterEnabled, overrides, pill);
140
+ return enabled === pill.enabled ? pill : { ...pill, enabled };
141
+ });
142
+ effectiveEnabled.clear();
143
+ for (const pill of effective) {
144
+ if (pill.enabled) effectiveEnabled.add(pill.id);
145
+ }
146
+ customPillPoller.updatePills(effective);
147
+ } catch {
148
+ // Directory might be temporarily inaccessible
149
+ }
150
+ }
151
+
152
+ export async function handleGetCustomPills(): Promise<{ pills: CustomPillState[] }> {
153
+ await refreshCustomPillConfigs();
154
+ return {
155
+ pills: customPillPoller
156
+ .getAllStates()
157
+ .filter((state) => effectiveEnabled.has(state.id)),
158
+ };
159
+ }
160
+
161
+ export async function handleListCustomPills(): Promise<{ pills: CustomPillDefinition[] }> {
162
+ const discovered = await discoverCustomPillConfigs(CUSTOM_PILLS_DIR, log);
163
+ const settings = await settingsStorage.readAsync();
164
+ const overrides = settings.customPillEnabled ?? {};
165
+ const masterEnabled = settings.showCustomPills ?? true;
166
+ return {
167
+ pills: discovered.map((pill) => {
168
+ const override = overrides[pill.id];
169
+ const enabled = masterEnabled && (override === undefined ? pill.enabled : override);
170
+ return enabled === pill.enabled ? pill : { ...pill, enabled };
171
+ }),
172
+ };
173
+ }
174
+
175
+ export async function handleRunCustomPillModalCommand(input: {
176
+ pillId: string;
177
+ }): Promise<{ output?: string; error?: string }> {
178
+ return customPillPoller.runModalCommand(input.pillId);
179
+ }
180
+
181
+ const settingsStorage = new PluginStorage<TopSettings>("top", "settings.json", {
182
+ schema: topSettingsContract.schema,
183
+ });
184
+
185
+ const mcpStorage = new PluginStorage<McpStatusSnapshot>("mcp-tools", "status.json");
186
+
187
+ interface McpPluginState {
188
+ running: boolean;
189
+ installed: boolean;
190
+ }
191
+
192
+ // Resolving plugin state shells out to the paseo CLI (a full node startup,
193
+ // ~120MB). Uncached per-RPC lookups stampede under client polling and
194
+ // fork-bomb the host, so share one in-flight lookup with a long TTL.
195
+ const MCP_STATE_TTL_MS = 30_000;
196
+ let mcpStateCache: { at: number; state: McpPluginState } | null = null;
197
+ let mcpStateInflight: Promise<McpPluginState> | null = null;
198
+
199
+ export async function getMcpPluginState(): Promise<McpPluginState> {
200
+ const now = Date.now();
201
+ if (mcpStateCache && now - mcpStateCache.at < MCP_STATE_TTL_MS) {
202
+ return mcpStateCache.state;
203
+ }
204
+ if (!mcpStateInflight) {
205
+ mcpStateInflight = (async (): Promise<McpPluginState> => {
206
+ const running = await isPluginRunning("mcp-tools");
207
+ const installed = await isPluginInstalled("mcp-tools");
208
+ const state = { running, installed };
209
+ mcpStateCache = { at: Date.now(), state };
210
+ return state;
211
+ })().finally(() => {
212
+ mcpStateInflight = null;
213
+ });
214
+ }
215
+ return mcpStateInflight;
216
+ }
217
+
218
+ const SETTINGS_READ_LOG_INTERVAL_MS = 30_000;
219
+ let lastSettingsReadLogAt = 0;
220
+
221
+ export async function handleGetSettings(): Promise<TopSettings> {
222
+ const data = await settingsStorage.readAsync();
223
+ // Reads arrive on every client mount/focus verification, so keep the
224
+ // observable signal but bound it: at most one line per interval, keys only
225
+ // on writes, never full payloads.
226
+ const now = Date.now();
227
+ if (now - lastSettingsReadLogAt > SETTINGS_READ_LOG_INTERVAL_MS) {
228
+ lastSettingsReadLogAt = now;
229
+ log.debug("Settings read requested");
230
+ }
231
+ return data;
232
+ }
233
+
234
+ export async function handleUpdateSettings(patch: Partial<TopSettings>): Promise<TopSettings> {
235
+ log.info("Settings update requested", { keys: Object.keys(patch) });
236
+ const updated = await settingsStorage.updateAsync((prev) => ({ ...prev, ...patch }));
237
+ log.info("Settings updated successfully");
238
+ if (patch.customPillEnabled !== undefined || patch.showCustomPills !== undefined) {
239
+ await refreshCustomPillConfigs();
240
+ }
241
+ return updated;
242
+ }
243
+
244
+ export async function handleResetSettings(): Promise<TopSettings> {
245
+ log.info("Settings reset requested");
246
+ settingsStorage.reset();
247
+ return topSettingsContract.defaultSettings;
248
+ }
249
+
250
+ export async function resolveGitBranch(dir?: string | null): Promise<string | null> {
251
+ if (!dir || typeof dir !== "string") return null;
252
+ try {
253
+ const gitPath = path.join(dir, ".git");
254
+ try {
255
+ await fs.promises.access(gitPath);
256
+ } catch {
257
+ return null;
258
+ }
259
+
260
+ let headFile: string | null = null;
261
+ const stat = await fs.promises.stat(gitPath);
262
+ if (stat.isDirectory()) {
263
+ headFile = path.join(gitPath, "HEAD");
264
+ } else if (stat.isFile()) {
265
+ // Worktree pointer file: "gitdir: <path>"
266
+ const content = (await fs.promises.readFile(gitPath, "utf8")).trim();
267
+ const match = content.match(/^gitdir:\s*(.+)$/m);
268
+ if (match && match[1]) {
269
+ const gitDir = path.resolve(dir, match[1]);
270
+ headFile = path.join(gitDir, "HEAD");
271
+ }
272
+ }
273
+
274
+ if (headFile) {
275
+ let headContent: string;
276
+ try {
277
+ headContent = (await fs.promises.readFile(headFile, "utf8")).trim();
278
+ } catch {
279
+ headContent = "";
280
+ }
281
+ if (!headContent) {
282
+ // fall through to git command
283
+ } else {
284
+ const branchMatch = headContent.match(/^ref:\s*refs\/heads\/(.+)$/);
285
+ if (branchMatch && branchMatch[1]) {
286
+ return branchMatch[1];
287
+ }
288
+ if (/^[0-9a-f]{7,40}$/i.test(headContent)) {
289
+ return headContent.slice(0, 7);
290
+ }
291
+ }
292
+ }
293
+ } catch {
294
+ // Ignore and fall through to git command
295
+ }
296
+
297
+ try {
298
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
299
+ cwd: dir,
300
+ timeout: 1500,
301
+ });
302
+ const branch = String(stdout).trim();
303
+ return branch && branch !== "HEAD" ? branch : null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ export async function handleGetSystemResources(input?: {
310
+ directory?: string;
311
+ fields?: ResourceField[];
312
+ }): Promise<SystemResources> {
313
+ const fields = input?.fields;
314
+ const isSelective = Array.isArray(fields) && fields.length > 0;
315
+
316
+ const needCpu = !isSelective || fields.includes("cpu");
317
+ const needMem = !isSelective || fields.includes("memory");
318
+ const needLoad = !isSelective || fields.includes("load");
319
+ const needUptime = !isSelective || fields.includes("uptime");
320
+ const needBranch = !isSelective || fields.includes("branch");
321
+ const needMcp = !isSelective || fields.includes("mcp");
322
+
323
+ const mcpState = needMcp ? await getMcpPluginState() : undefined;
324
+ const mcpRunning = mcpState?.running;
325
+ const mcpInstalled = mcpState?.installed;
326
+
327
+ let branch: string | null | undefined = undefined;
328
+ if (needBranch) {
329
+ branch = await resolveGitBranch(input?.directory);
330
+ }
331
+
332
+ let cpuUsagePercent: number | undefined = undefined;
333
+ let usedMem: number | undefined = undefined;
334
+ let totalMem: number | undefined = undefined;
335
+ let memoryUsedPercent: number | undefined = undefined;
336
+ let loadAvg: number[] | undefined = undefined;
337
+ let uptimeSeconds: number | undefined = undefined;
338
+
339
+ let hostname: string | undefined = undefined;
340
+ let platform: string | undefined = undefined;
341
+ let arch: string | undefined = undefined;
342
+ let cpuModel: string | undefined = undefined;
343
+ let cpuCores: number | undefined = undefined;
344
+
345
+ if (needCpu || !isSelective) {
346
+ const metrics = getSystemMetrics();
347
+ cpuUsagePercent = metrics.cpu.usagePercent;
348
+ if (!isSelective) {
349
+ hostname = metrics.hostname;
350
+ platform = `${os.type()} ${os.release()}`;
351
+ arch = metrics.arch;
352
+ cpuModel = metrics.cpu.model;
353
+ cpuCores = metrics.cpu.cores;
354
+ loadAvg = metrics.cpu.loadAverage;
355
+ uptimeSeconds = metrics.uptimeSeconds;
356
+ totalMem = metrics.memory.totalBytes;
357
+ usedMem = metrics.memory.usedBytes;
358
+ memoryUsedPercent = Math.round(metrics.memory.usedPercent);
359
+ }
360
+ }
361
+
362
+ if (needMem) {
363
+ if (totalMem === undefined) {
364
+ totalMem = os.totalmem();
365
+ const freeMem = os.freemem();
366
+ usedMem = totalMem - freeMem;
367
+ memoryUsedPercent = Math.round((usedMem / totalMem) * 100);
368
+ }
369
+
370
+ if (process.platform === "linux") {
371
+ try {
372
+ const meminfo = await fs.promises.readFile("/proc/meminfo", "utf8");
373
+ const match = meminfo.match(/MemAvailable:\s+(\d+)\s+kB/);
374
+ if (match && match[1]) {
375
+ const available = parseInt(match[1], 10) * 1024;
376
+ usedMem = Math.max(0, totalMem - available);
377
+ memoryUsedPercent = Math.round((usedMem / totalMem) * 100);
378
+ }
379
+ } catch {
380
+ // Fallback to metrics.memory
381
+ }
382
+ }
383
+ }
384
+
385
+ if (needLoad && loadAvg === undefined) {
386
+ loadAvg = os.loadavg();
387
+ }
388
+
389
+ if (needUptime && uptimeSeconds === undefined) {
390
+ uptimeSeconds = Math.floor(os.uptime());
391
+ }
392
+
393
+ let mcp: McpResourceStatus | null = null;
394
+ if (needMcp && mcpRunning && mcpStorage.exists()) {
395
+ try {
396
+ const snapshot = await mcpStorage.readAsync();
397
+ if (snapshot && typeof snapshot.updatedAt === "string" && Array.isArray(snapshot.servers)) {
398
+ const isStale = Date.now() - new Date(snapshot.updatedAt).getTime() > 60_000;
399
+ mcp = {
400
+ updatedAt: snapshot.updatedAt,
401
+ total: typeof snapshot.total === "number" ? snapshot.total : snapshot.servers.length,
402
+ healthy: typeof snapshot.healthy === "number" ? snapshot.healthy : 0,
403
+ degraded: typeof snapshot.degraded === "number" ? snapshot.degraded : 0,
404
+ down: typeof snapshot.down === "number" ? snapshot.down : 0,
405
+ isStale,
406
+ servers: snapshot.servers,
407
+ };
408
+ }
409
+ } catch {
410
+ mcp = null;
411
+ }
412
+ }
413
+
414
+ if (!mcpRunning) {
415
+ mcp = null;
416
+ }
417
+
418
+ return {
419
+ version: PLUGIN_VERSION,
420
+ hostname,
421
+ platform,
422
+ arch,
423
+ cpuModel,
424
+ cpuCores,
425
+ cpuUsagePercent,
426
+ memoryUsedBytes: usedMem,
427
+ memoryTotalBytes: totalMem,
428
+ memoryUsedPercent,
429
+ loadAvg,
430
+ uptimeSeconds,
431
+ branch,
432
+ mcp,
433
+ mcpInstalled,
434
+ mcpRunning,
435
+ customPills: customPillPoller
436
+ .getAllStates()
437
+ .filter((state) => effectiveEnabled.has(state.id)),
438
+ lastTurn: lastTurnTelemetry,
439
+ liveUsage: lastLiveUsage,
440
+ };
441
+ }
442
+
443
+ export interface GitDiffStat {
444
+ insertions: number;
445
+ deletions: number;
446
+ filesChanged: number;
447
+ }
448
+
449
+ export function parseGitDiffShortstat(output: string): GitDiffStat | null {
450
+ const match = output.match(/(\d+)\s+files? changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?/);
451
+ if (!match) return null;
452
+ return {
453
+ filesChanged: parseInt(match[1], 10),
454
+ insertions: match[2] ? parseInt(match[2], 10) : 0,
455
+ deletions: match[3] ? parseInt(match[3], 10) : 0,
456
+ };
457
+ }
458
+
459
+ export async function collectGitDiffStat(cwd: string): Promise<GitDiffStat | null> {
460
+ try {
461
+ const { stdout } = await execFileAsync("git", ["diff", "--shortstat"], {
462
+ cwd,
463
+ timeout: 2000,
464
+ });
465
+ const trimmed = String(stdout).trim();
466
+ if (!trimmed) return { insertions: 0, deletions: 0, filesChanged: 0 };
467
+ return parseGitDiffShortstat(trimmed);
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+
473
+ export interface TurnUsage {
474
+ inputTokens?: number;
475
+ outputTokens?: number;
476
+ cachedTokens?: number;
477
+ cachedInputTokens?: number;
478
+ contextUsedTokens?: number;
479
+ contextMaxTokens?: number;
480
+ costUsd?: number;
481
+ }
482
+
483
+ export interface TurnActivity {
484
+ toolCalls: number;
485
+ toolErrors: number;
486
+ usage?: TurnUsage;
487
+ }
488
+
489
+ function readUsageRecord(record: unknown): TurnUsage | undefined {
490
+ if (!record || typeof record !== "object") return undefined;
491
+ const r = record as Record<string, unknown>;
492
+ const num = (v: unknown): number | undefined =>
493
+ typeof v === "number" && Number.isFinite(v) ? v : undefined;
494
+ const usage: TurnUsage = {
495
+ inputTokens: num(r.inputTokens),
496
+ outputTokens: num(r.outputTokens),
497
+ cachedTokens: num(r.cachedTokens ?? r.cachedInputTokens),
498
+ cachedInputTokens: num(r.cachedInputTokens ?? r.cachedTokens),
499
+ contextUsedTokens: num(r.contextWindowUsedTokens ?? r.contextUsedTokens),
500
+ contextMaxTokens: num(r.contextWindowMaxTokens ?? r.contextMaxTokens),
501
+ costUsd: num(r.totalCostUsd ?? r.costUsd),
502
+ };
503
+ if (
504
+ usage.inputTokens === undefined &&
505
+ usage.outputTokens === undefined &&
506
+ usage.cachedTokens === undefined &&
507
+ usage.contextUsedTokens === undefined &&
508
+ usage.contextMaxTokens === undefined &&
509
+ usage.costUsd === undefined
510
+ ) {
511
+ return undefined;
512
+ }
513
+ return usage;
514
+ }
515
+
516
+ /**
517
+ * Lifetime turn count for an agent: one turn per user message in the full
518
+ * timeline history the daemon hands us on turn_ended. All outcomes count —
519
+ * a failed turn still consumed context. Returns undefined when no timeline
520
+ * was provided so callers render a placeholder instead of a false zero.
521
+ */
522
+ export function countTurns(timeline: readonly unknown[] | undefined): number | undefined {
523
+ if (!timeline) return undefined;
524
+ let count = 0;
525
+ for (const item of timeline) {
526
+ if (!item || typeof item !== "object") continue;
527
+ if ((item as Record<string, unknown>).type === "user_message") count++;
528
+ }
529
+ return count;
530
+ }
531
+
532
+ export function summarizeTurnTimeline(timeline: readonly unknown[]): TurnActivity {
533
+ let toolCalls = 0;
534
+ let toolErrors = 0;
535
+ let usage: TurnUsage | undefined;
536
+ for (const item of timeline) {
537
+ if (!item || typeof item !== "object") continue;
538
+ const row = item as Record<string, unknown>;
539
+ const type = row.type;
540
+ if (type === "tool_call") {
541
+ toolCalls++;
542
+ const detail = row.detail as Record<string, unknown> | undefined;
543
+ const status = row.status ?? detail?.status;
544
+ if (status === "failed" || status === "error") toolErrors++;
545
+ }
546
+ if (type === "usage_updated" || type === "turn_completed") {
547
+ const found = readUsageRecord(
548
+ row.usage ?? row.detail ?? (row.detail as any)?.usage ?? (row as any).data?.usage,
549
+ );
550
+ if (found) usage = { ...usage, ...found };
551
+ }
552
+ }
553
+ return { toolCalls, toolErrors, usage };
554
+ }
555
+
556
+ /**
557
+ * A terminal event whose turnId differs from the turn that turn_started opened
558
+ * cannot be that turn's end: on interrupt the daemon's rescue path emits an
559
+ * extra terminal after the real one, sometimes with no turnId at all. Providers
560
+ * that never tag turns (codex/pi) keep a null id, so null-over-null is not
561
+ * stale.
562
+ */
563
+ export function isStaleTurnEnd(
564
+ activeTurnId: string | null | undefined,
565
+ eventTurnId: string | null,
566
+ ): boolean {
567
+ return activeTurnId != null && eventTurnId !== activeTurnId;
568
+ }
569
+
570
+ /** Upper bound on how long after a cancel the daemon's echo can still land. */
571
+ export const INTERRUPT_ECHO_WINDOW_MS = 5_000;
572
+
573
+ export interface InterruptEchoInput {
574
+ /** Wall time the last canceled terminal was recorded, or null if none. */
575
+ lastCanceledAt: number | null;
576
+ /** Lifetime user_message count seen on that canceled terminal's timeline. */
577
+ lastCanceledUserMessages: number | null;
578
+ /** Lifetime user_message count on the terminal under test. */
579
+ eventUserMessages: number;
580
+ now: number;
581
+ }
582
+
583
+ /**
584
+ * On interrupt the daemon/provider emits a second terminal for the turn the
585
+ * cancel already ended — typically a 0-duration `turn_completed` reusing a
586
+ * freshly minted turnId, or a `turn_canceled` with no turnId. It carries no new
587
+ * user message, so matching the timeline's user_message count against the
588
+ * cancel's identifies it as the same interrupt rather than a real follow-up.
589
+ */
590
+ export function isInterruptEcho(input: InterruptEchoInput): boolean {
591
+ if (input.lastCanceledAt == null || input.lastCanceledUserMessages == null) return false;
592
+ if (input.now - input.lastCanceledAt > INTERRUPT_ECHO_WINDOW_MS) return false;
593
+ return input.eventUserMessages <= input.lastCanceledUserMessages;
594
+ }
595
+
596
+ let lastTurnTelemetry: TopTimelineTelemetryData | null = null;
597
+
598
+ let lastLiveUsage: LiveUsage | null = null;
599
+
600
+ export function getLastTurnTelemetry(): TopTimelineTelemetryData | null {
601
+ return lastTurnTelemetry;
602
+ }
603
+
604
+ export function setLastLiveUsage(usage: LiveUsage | null): void {
605
+ if (usage) {
606
+ lastLiveUsage = { ...(lastLiveUsage ?? {}), ...usage };
607
+ }
608
+ }
609
+
610
+ export function getLastLiveUsage(): LiveUsage | null {
611
+ return lastLiveUsage;
612
+ }
613
+
614
+ export async function collectTurnTelemetry(
615
+ turnId: string | null,
616
+ agentId: string,
617
+ outcome: {
618
+ kind: "completed" | "failed" | "canceled";
619
+ error?: { message: string; code?: string };
620
+ reason?: string;
621
+ },
622
+ durationMs?: number,
623
+ extra?: {
624
+ cwd?: string | null;
625
+ provider?: string | null;
626
+ title?: string | null;
627
+ model?: string | null;
628
+ timeline?: readonly unknown[];
629
+ gitBefore?: GitDiffStat | null;
630
+ mcpRunning?: boolean;
631
+ mcpInstalled?: boolean;
632
+ mcpStorage?: Pick<PluginStorage<McpStatusSnapshot>, "exists" | "readAsync">;
633
+ liveUsage?: LiveUsage | null;
634
+ },
635
+ ): Promise<TopTimelineTelemetryData> {
636
+ const metrics = getSystemMetrics();
637
+ let totalMem = metrics.memory.totalBytes;
638
+ let usedMem = metrics.memory.usedBytes;
639
+ let memPercent = Math.round(metrics.memory.usedPercent);
640
+
641
+ if (process.platform === "linux") {
642
+ try {
643
+ const meminfo = await fs.promises.readFile("/proc/meminfo", "utf8");
644
+ const match = meminfo.match(/MemAvailable:\s+(\d+)\s+kB/);
645
+ if (match && match[1]) {
646
+ const available = parseInt(match[1], 10) * 1024;
647
+ usedMem = Math.max(0, totalMem - available);
648
+ memPercent = Math.round((usedMem / totalMem) * 100);
649
+ }
650
+ } catch {
651
+ // Fallback to metrics.memory
652
+ }
653
+ }
654
+
655
+ const loadAvg = os.loadavg();
656
+ const loadAvg1m = loadAvg.length > 0 ? Math.round(loadAvg[0] * 100) / 100 : 0;
657
+
658
+ let mcpHealthy: number | undefined;
659
+ let mcpTotal: number | undefined;
660
+ // Gate on live plugin state, not just the snapshot file: after mcp-tools
661
+ // is removed its status.json stays on disk and would otherwise bake stale
662
+ // counts into every new timeline item. Same check as the live RPC path.
663
+ const cachedMcp = extra?.mcpRunning === undefined ? await getMcpPluginState() : null;
664
+ const mcpRunning = extra?.mcpRunning ?? cachedMcp?.running ?? false;
665
+ const mcpInstalled = extra?.mcpInstalled ?? cachedMcp?.installed ?? false;
666
+ const mcpSnapshotSource = extra?.mcpStorage ?? mcpStorage;
667
+ if (mcpRunning && mcpSnapshotSource.exists()) {
668
+ try {
669
+ const snap = await mcpSnapshotSource.readAsync();
670
+ if (snap && typeof snap.total === "number") {
671
+ mcpTotal = snap.total;
672
+ mcpHealthy = snap.healthy;
673
+ }
674
+ } catch {
675
+ // Ignore
676
+ }
677
+ }
678
+
679
+ let outcomeError: string | undefined;
680
+ if (outcome.kind === "failed" && outcome.error) {
681
+ outcomeError = outcome.error.message;
682
+ } else if (outcome.kind === "canceled" && outcome.reason) {
683
+ outcomeError = outcome.reason;
684
+ }
685
+
686
+ const cwd = extra?.cwd ?? null;
687
+ const branch = await resolveGitBranch(cwd);
688
+ const uptimeSeconds = Math.floor(os.uptime());
689
+
690
+ let gitInsertions: number | undefined;
691
+ let gitDeletions: number | undefined;
692
+ let gitFilesChanged: number | undefined;
693
+ if (cwd) {
694
+ const before = extra?.gitBefore ?? null;
695
+ const after = await collectGitDiffStat(cwd);
696
+ if (after) {
697
+ gitInsertions = Math.max(0, after.insertions - (before?.insertions ?? 0));
698
+ gitDeletions = Math.max(0, after.deletions - (before?.deletions ?? 0));
699
+ gitFilesChanged = after.filesChanged;
700
+ }
701
+ }
702
+
703
+ let toolCalls: number | undefined;
704
+ let toolErrors: number | undefined;
705
+ let turnCount: number | undefined;
706
+ let inputTokens: number | undefined;
707
+ let outputTokens: number | undefined;
708
+ let cachedTokens: number | undefined;
709
+ let contextUsedTokens: number | undefined;
710
+ let contextMaxTokens: number | undefined;
711
+ let costUsd: number | undefined;
712
+ if (extra?.timeline) {
713
+ const activity = summarizeTurnTimeline(extra.timeline);
714
+ toolCalls = activity.toolCalls;
715
+ toolErrors = activity.toolErrors;
716
+ turnCount = countTurns(extra.timeline);
717
+ inputTokens = activity.usage?.inputTokens;
718
+ outputTokens = activity.usage?.outputTokens;
719
+ cachedTokens = activity.usage?.cachedTokens ?? activity.usage?.cachedInputTokens;
720
+ contextUsedTokens = activity.usage?.contextUsedTokens;
721
+ contextMaxTokens = activity.usage?.contextMaxTokens;
722
+ costUsd = activity.usage?.costUsd;
723
+ }
724
+
725
+ // Fall back to per-turn liveUsage, then lastLiveUsage, if timeline omitted tokens
726
+ const effectiveLiveUsage = extra?.liveUsage ?? lastLiveUsage;
727
+ if (inputTokens === undefined && effectiveLiveUsage?.inputTokens !== undefined) {
728
+ inputTokens = effectiveLiveUsage.inputTokens;
729
+ }
730
+ if (outputTokens === undefined && effectiveLiveUsage?.outputTokens !== undefined) {
731
+ outputTokens = effectiveLiveUsage.outputTokens;
732
+ }
733
+ if (cachedTokens === undefined && effectiveLiveUsage?.cachedInputTokens !== undefined) {
734
+ cachedTokens = effectiveLiveUsage.cachedInputTokens;
735
+ }
736
+ if (contextUsedTokens === undefined && effectiveLiveUsage?.contextWindowUsedTokens !== undefined) {
737
+ contextUsedTokens = effectiveLiveUsage.contextWindowUsedTokens;
738
+ }
739
+ if (contextMaxTokens === undefined && effectiveLiveUsage?.contextWindowMaxTokens !== undefined) {
740
+ contextMaxTokens = effectiveLiveUsage.contextWindowMaxTokens;
741
+ }
742
+ if (costUsd === undefined && effectiveLiveUsage?.totalCostUsd !== undefined) {
743
+ costUsd = effectiveLiveUsage.totalCostUsd;
744
+ }
745
+
746
+ const data: TopTimelineTelemetryData = {
747
+ turnId,
748
+ agentId,
749
+ outcomeKind: outcome.kind,
750
+ outcomeError,
751
+ timestamp: new Date().toISOString(),
752
+ durationMs,
753
+ cpuPercent: Math.round(metrics.cpu.usagePercent),
754
+ memUsedBytes: usedMem,
755
+ memTotalBytes: totalMem,
756
+ memPercent,
757
+ loadAvg1m,
758
+ mcpHealthy,
759
+ mcpTotal,
760
+ mcpInstalled,
761
+ mcpRunning,
762
+ branch,
763
+ worktree: cwd,
764
+ agentTitle: extra?.title ?? null,
765
+ agentModel: extra?.model ?? null,
766
+ agentProvider: extra?.provider ?? null,
767
+ uptimeSeconds,
768
+ gitInsertions,
769
+ gitDeletions,
770
+ gitFilesChanged,
771
+ toolCalls,
772
+ toolErrors,
773
+ turnCount,
774
+ inputTokens,
775
+ outputTokens,
776
+ cachedTokens,
777
+ cachedInputTokens: cachedTokens,
778
+ contextUsedTokens,
779
+ contextMaxTokens,
780
+ costUsd,
781
+ };
782
+ lastTurnTelemetry = data;
783
+ return data;
784
+ }