@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.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/client/pill-labels.ts +480 -0
- package/client/pill.tsx +2461 -0
- package/client/resources-query.ts +68 -0
- package/client/settings-ui.tsx +50 -0
- package/client/surface.tsx +364 -0
- package/client/telemetry-copy.ts +105 -0
- package/client/telemetry.tsx +486 -0
- package/client/turn-counter.ts +151 -0
- package/client/turn-panel.tsx +182 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/examples/pills/battery.jsonc +23 -0
- package/examples/pills/disk-usage.jsonc +18 -0
- package/examples/pills/docker-containers.jsonc +22 -0
- package/examples/pills/git-dirty.jsonc +22 -0
- package/examples/pills/gpu-nvidia.jsonc +22 -0
- package/package.json +37 -0
- package/paseo-plugin.json +4 -0
- package/server/resources.ts +784 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/resources.ts +686 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- 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
|
+
}
|