@xpufx/paseo-top 0.4.0 → 0.4.1

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.
@@ -1,4 +1,4 @@
1
- import { formatUptime } from "paseo-plugin-helper/shared";
1
+ import { formatBytes, formatUptime } from "paseo-plugin-helper/shared";
2
2
  import { formatCompactTokens, type TopAgentSnapshot } from "./pill-labels";
3
3
  import type { TopTimelineTelemetryData } from "../shared/resources";
4
4
 
@@ -55,9 +55,16 @@ export function buildTelemetryCopyText({ data, liveUsage, timeLabel }: Telemetry
55
55
  const time = timeLabel ?? data.timestamp;
56
56
  if (time) lines.push(time);
57
57
 
58
+ // `turnId` is normally an opaque id, but some providers use the source
59
+ // issue URL. Keep it as a labeled reference: it is useful context, but it
60
+ // must never become the whole clipboard payload.
61
+ if (data.turnId) lines.push(`Reference ${data.turnId}`);
62
+
58
63
  const id = shortAgentId(data.agentId);
59
64
  if (id) lines.push(`Agent ${id}`);
60
65
 
66
+ if (data.agentTitle) lines.push(`Title ${data.agentTitle}`);
67
+
61
68
  const model = data.agentModel ?? undefined;
62
69
  const provider = data.agentProvider ?? undefined;
63
70
  if (model || provider) {
@@ -67,6 +74,13 @@ export function buildTelemetryCopyText({ data, liveUsage, timeLabel }: Telemetry
67
74
  if (data.branch) lines.push(`Branch ${data.branch}`);
68
75
  if (data.worktree) lines.push(`Worktree ${data.worktree}`);
69
76
 
77
+ // These are the first row of the rendered card body. Keep their labels and
78
+ // formatting aligned with the card so Copy is a human-readable export of
79
+ // what the operator sees, rather than just an identifier or URL.
80
+ lines.push(`CPU ${data.cpuPercent}%`);
81
+ lines.push(`RAM ${formatBytes(data.memUsedBytes)} (${data.memPercent}%)`);
82
+ lines.push(`Load ${data.loadAvg1m.toFixed(2)}`);
83
+
70
84
  const totalTokens =
71
85
  inputTokens != null || outputTokens != null
72
86
  ? (inputTokens ?? 0) + (outputTokens ?? 0)
@@ -24,16 +24,23 @@ export interface ClipboardEnvironment {
24
24
  * `react-native-web` `Clipboard.setString` reports success even when its
25
25
  * `document.execCommand("copy")` fails, which leaves the previous clipboard
26
26
  * item in place while the UI claims success (xpufx-org/paseo#278); it is only
27
- * usable off-DOM (native), where it is the real platform clipboard. In a DOM
28
- * the checked `execCommand` fallback is preferred to that unverifiable path.
27
+ * usable off-DOM (native), where it is the real platform clipboard. The same
28
+ * applies to any `setStringAsync` whose DOM fallback is that unverified
29
+ * `execCommand`. In a DOM the checked `execCommand` fallback is preferred to
30
+ * those unverifiable paths.
29
31
  */
30
32
  export function clipboardTierOrder(env: ClipboardEnvironment): ClipboardTier[] {
31
33
  const tiers: ClipboardTier[] = [];
32
34
  if (env.hasNavigatorClipboard) tiers.push("navigator");
33
35
  if (env.hasHostCopyText) tiers.push("host");
34
- if (env.hasRnSetStringAsync) {
36
+ // On a DOM both RN-web paths are unverifiable: `setString` reports success
37
+ // even when its `execCommand` no-ops, and a `setStringAsync` that falls back
38
+ // to it does the same. Off-DOM they are the real native clipboard, so they
39
+ // lead there; on a DOM only the checked `execCommand` fallback is honest.
40
+ const rnDomOk = !env.isDom;
41
+ if (env.hasRnSetStringAsync && rnDomOk) {
35
42
  tiers.push("rnAsync");
36
- } else if (env.hasRnClipboard && !env.isDom) {
43
+ } else if (env.hasRnClipboard && rnDomOk) {
37
44
  tiers.push("rnSync");
38
45
  }
39
46
  tiers.push("execCommand");
@@ -0,0 +1,71 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { useRpc } from "@getpaseo/plugin/client";
3
+ import {
4
+ Icon,
5
+ Modal,
6
+ useToast,
7
+ ScrollView,
8
+ FlatList,
9
+ TextInput as HostTextInput,
10
+ copyText,
11
+ } from "@getpaseo/plugin/client/react-native";
12
+ import { initClientHelpers } from "paseo-plugin-helper/client";
13
+ import { contributeClient } from "./client/pill";
14
+ import { TopTimelineTelemetryCard } from "./client/telemetry";
15
+ import { TurnCounterPanel } from "./client/turn-panel";
16
+ import {
17
+ TOP_TIMELINE_KIND,
18
+ TOP_TIMELINE_VERSION,
19
+ topTimelineTelemetrySchema,
20
+ } from "./shared/resources";
21
+
22
+ initClientHelpers({
23
+ Icon,
24
+ Modal,
25
+ useRpc,
26
+ useToast,
27
+ copyText,
28
+ ScrollView,
29
+ FlatList,
30
+ TextInput: HostTextInput,
31
+ });
32
+
33
+ export default function contribute(client: PluginClientContext) {
34
+ const removeTimelineRenderer = client.addTimelineRenderer({
35
+ kind: TOP_TIMELINE_KIND,
36
+ version: TOP_TIMELINE_VERSION,
37
+ schema: topTimelineTelemetrySchema,
38
+ Component: TopTimelineTelemetryCard,
39
+ });
40
+
41
+ const cleanupClient = contributeClient(client);
42
+
43
+ // Agent-scoped panel: exposes the deterministic turn index for the active
44
+ // conversation (Turn #N + canonical seq) so a human and the agent resolve the
45
+ // same reference. See client/turn-counter.ts. Agent panels are opened through
46
+ // a command-center item, which forwards the active agent's openPanel.
47
+ const removeTurnPanel = client.addWorkspacePanel({
48
+ id: "top-turn-counter",
49
+ title: "Turn Counter",
50
+ icon: "Repeat",
51
+ context: "agent",
52
+ Component: TurnCounterPanel,
53
+ });
54
+ const removeTurnCommand = client.addCommandCenterItem({
55
+ id: "top-turn-counter",
56
+ title: "Turn Counter",
57
+ icon: "Repeat",
58
+ keywords: ["turn", "seq", "counter", "conversation"],
59
+ context: "agent",
60
+ onSelect(ctx) {
61
+ ctx.openPanel("top-turn-counter");
62
+ },
63
+ });
64
+
65
+ return () => {
66
+ removeTurnCommand();
67
+ removeTurnPanel();
68
+ removeTimelineRenderer();
69
+ cleanupClient();
70
+ };
71
+ }
@@ -0,0 +1,290 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { guardRpcHandler } from "paseo-plugin-helper/server";
3
+ import {
4
+ getSystemResourcesRpc,
5
+ getCustomPillsRpc,
6
+ listCustomPillsRpc,
7
+ runCustomPillModalCommandRpc,
8
+ topSettingsContract,
9
+ TOP_TIMELINE_KIND,
10
+ TOP_TIMELINE_VERSION,
11
+ type LiveUsage,
12
+ type SystemResources,
13
+ } from "./shared/resources";
14
+ import {
15
+ handleGetSystemResources,
16
+ handleGetCustomPills,
17
+ handleListCustomPills,
18
+ handleRunCustomPillModalCommand,
19
+ handleGetSettings,
20
+ handleUpdateSettings,
21
+ handleResetSettings,
22
+ customPillPoller,
23
+ collectTurnTelemetry,
24
+ collectGitDiffStat,
25
+ countTurns,
26
+ getLastLiveUsage,
27
+ isInterruptEcho,
28
+ isStaleTurnEnd,
29
+ setLastLiveUsage,
30
+ log,
31
+ } from "./server/resources";
32
+ import { resolveTimelineCadence, shouldAppendTimelineForTurn } from "./shared/resources";
33
+
34
+ export default function contribute(server: PluginServerContext) {
35
+ void customPillPoller.start();
36
+
37
+ // Shed load instead of hanging the daemon RPC: saturated or slow handlers
38
+ // answer from the last good snapshot (system-resources) or fail fast.
39
+ // WARNs are rate-limited (one per minute per cause): saturation fires once
40
+ // per poll tick per caller while the modal is open, which flooded the log.
41
+ let lastSystemResources: SystemResources | null = null;
42
+ const lastWarnAt = { timeout: 0, saturated: 0 };
43
+ const WARN_COOLDOWN_MS = 60_000;
44
+ const guardedSystemResources = guardRpcHandler(
45
+ async (input: Parameters<typeof handleGetSystemResources>[0]) => {
46
+ const resources = await handleGetSystemResources(input);
47
+ lastSystemResources = resources;
48
+ return resources;
49
+ },
50
+ {
51
+ timeoutMs: 5000,
52
+ maxInflight: 4,
53
+ getStale: () => lastSystemResources,
54
+ onTimeout: ({ timeoutMs }) => {
55
+ const now = Date.now();
56
+ if (now - lastWarnAt.timeout < WARN_COOLDOWN_MS) return;
57
+ lastWarnAt.timeout = now;
58
+ log.warn("system-resources handler timed out", { timeoutMs });
59
+ },
60
+ onSaturated: ({ maxInflight }) => {
61
+ const now = Date.now();
62
+ if (now - lastWarnAt.saturated < WARN_COOLDOWN_MS) return;
63
+ lastWarnAt.saturated = now;
64
+ log.warn("system-resources handler saturated, serving stale", { maxInflight });
65
+ },
66
+ },
67
+ );
68
+
69
+ server.handle(topSettingsContract.get, handleGetSettings);
70
+ server.handle(topSettingsContract.update, handleUpdateSettings);
71
+ server.handle(topSettingsContract.reset, handleResetSettings);
72
+ server.handle(getSystemResourcesRpc, guardedSystemResources);
73
+ server.handle(getCustomPillsRpc, handleGetCustomPills);
74
+ server.handle(listCustomPillsRpc, handleListCustomPills);
75
+ server.handle(runCustomPillModalCommandRpc, handleRunCustomPillModalCommand);
76
+
77
+ const turnStartTimes = new Map<string, number>();
78
+ const turnGitBefore = new Map<string, { insertions: number; deletions: number; filesChanged: number }>();
79
+ // Live turn per agent, set on turn_started and consumed by its terminal, used
80
+ // to drop terminals that cannot belong to it (see isStaleTurnEnd).
81
+ const activeTurnIds = new Map<string, string | null>();
82
+ // Time and timeline user_message count of the last canceled terminal, used to
83
+ // drop the daemon's extra interrupt terminal (see isInterruptEcho).
84
+ const lastCanceledAt = new Map<string, number>();
85
+ const lastCanceledUserMessages = new Map<string, number>();
86
+ // Per-agent turn counter for the timeline cadence option (0 = never,
87
+ // 1 = every turn, N>1 = every Nth turn). Counts deduped turn_ended events.
88
+ const turnCounters = new Map<string, number>();
89
+
90
+ const unsubscribeTurnStarted = server.on("agent.turn_started", (event, context) => {
91
+ turnStartTimes.set(event.agent.id, Date.now());
92
+ activeTurnIds.set(event.agent.id, event.turnId ?? null);
93
+ if ((event.agent as any)?.lastUsage) {
94
+ setLastLiveUsage((event.agent as any).lastUsage);
95
+ }
96
+ void context.paseo.agents
97
+ .ref(event.agent.id)
98
+ .refresh()
99
+ .then((refetched) => {
100
+ if (refetched && (refetched.agent as any)?.lastUsage) {
101
+ setLastLiveUsage((refetched.agent as any).lastUsage);
102
+ }
103
+ })
104
+ .catch(() => {});
105
+ void collectGitDiffStat(event.agent.cwd).then(
106
+ (before) => {
107
+ if (before) {
108
+ turnGitBefore.set(event.agent.id, before);
109
+ } else {
110
+ turnGitBefore.delete(event.agent.id);
111
+ }
112
+ },
113
+ () => {
114
+ turnGitBefore.delete(event.agent.id);
115
+ },
116
+ );
117
+ });
118
+
119
+ const unsubscribeAgentCreated = server.on("agent.created", (event) => {
120
+ if ((event.agent as any)?.lastUsage) {
121
+ setLastLiveUsage((event.agent as any).lastUsage);
122
+ }
123
+ });
124
+
125
+ async function appendTurnCard(
126
+ event: any,
127
+ context: any,
128
+ startTime: number | undefined,
129
+ gitBefore: { insertions: number; deletions: number; filesChanged: number } | undefined,
130
+ turnIndex: number,
131
+ ): Promise<void> {
132
+ try {
133
+ const settings = await handleGetSettings();
134
+ const cadence = resolveTimelineCadence(settings);
135
+ if (!shouldAppendTimelineForTurn(cadence, turnIndex)) {
136
+ return;
137
+ }
138
+ const durationMs = startTime ? Date.now() - startTime : undefined;
139
+
140
+ let agentModel: string | null = null;
141
+ let agentProvider: string | null = event.agent.provider ?? null;
142
+ let agentTitle: string | null = event.agent.title ?? null;
143
+ try {
144
+ const refetched = await context.paseo.agents.ref(event.agent.id).refresh();
145
+ agentModel = refetched?.agent?.model ?? agentModel;
146
+ agentProvider = refetched?.agent?.provider ?? agentProvider;
147
+ agentTitle = refetched?.agent?.title ?? agentTitle;
148
+ let liveUsage =
149
+ (refetched?.agent?.lastUsage as LiveUsage | null | undefined) ??
150
+ ((event.agent as any)?.lastUsage as LiveUsage | null | undefined) ??
151
+ null;
152
+ const lacksTokens =
153
+ liveUsage?.inputTokens == null &&
154
+ liveUsage?.outputTokens == null &&
155
+ (liveUsage as any)?.contextWindowUsedTokens == null;
156
+ if (lacksTokens) {
157
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
158
+ await sleep(150);
159
+ try {
160
+ const retry1 = await context.paseo.agents.ref(event.agent.id).refresh();
161
+ liveUsage =
162
+ (retry1?.agent?.lastUsage as LiveUsage | null | undefined) ?? liveUsage;
163
+ } catch {}
164
+ const stillLacks =
165
+ liveUsage?.inputTokens == null &&
166
+ liveUsage?.outputTokens == null &&
167
+ (liveUsage as any)?.contextWindowUsedTokens == null;
168
+ if (stillLacks) {
169
+ await sleep(250);
170
+ try {
171
+ const retry2 = await context.paseo.agents.ref(event.agent.id).refresh();
172
+ liveUsage =
173
+ (retry2?.agent?.lastUsage as LiveUsage | null | undefined) ?? liveUsage;
174
+ } catch {}
175
+ }
176
+ }
177
+ setLastLiveUsage(liveUsage);
178
+ } catch {
179
+ // Model, provider, and title stay at event snapshot values; the card renders placeholders
180
+ }
181
+
182
+ const telemetry = await collectTurnTelemetry(
183
+ event.turnId,
184
+ event.agent.id,
185
+ event.outcome,
186
+ durationMs,
187
+ {
188
+ cwd: event.agent.cwd,
189
+ provider: agentProvider,
190
+ title: agentTitle,
191
+ model: agentModel,
192
+ timeline: event.timeline,
193
+ gitBefore: gitBefore ?? null,
194
+ liveUsage: getLastLiveUsage(),
195
+ },
196
+ );
197
+
198
+ await context.paseo.agents.ref(event.agent.id).timeline.append({
199
+ type: "plugin",
200
+ id: `top-turn-${event.turnId ?? Date.now()}`,
201
+ kind: TOP_TIMELINE_KIND,
202
+ version: TOP_TIMELINE_VERSION,
203
+ data: telemetry,
204
+ });
205
+
206
+ log.info("Appended turn telemetry to timeline", {
207
+ agentId: event.agent.id,
208
+ turnId: event.turnId,
209
+ outcome: event.outcome.kind,
210
+ durationMs,
211
+ cpuPercent: telemetry.cpuPercent,
212
+ memPercent: telemetry.memPercent,
213
+ });
214
+ } catch (err) {
215
+ log.warn("Failed to record turn telemetry", {
216
+ agentId: event.agent.id,
217
+ turnId: event.turnId,
218
+ error: err instanceof Error ? err.message : String(err),
219
+ });
220
+ }
221
+ }
222
+
223
+ const unsubscribeTurnEnded = server.on("agent.turn_ended", (event, context) => {
224
+ const settingsPromise = handleGetSettings();
225
+ void settingsPromise.then((settings) => {
226
+ if (settings.recordTurnTelemetry === false) {
227
+ turnStartTimes.delete(event.agent.id);
228
+ turnGitBefore.delete(event.agent.id);
229
+ activeTurnIds.delete(event.agent.id);
230
+ lastCanceledAt.delete(event.agent.id);
231
+ lastCanceledUserMessages.delete(event.agent.id);
232
+ return;
233
+ }
234
+
235
+ // On interrupt the daemon emits a second terminal for the turn the cancel
236
+ // already ended. Skip it so the interrupt renders as one card.
237
+ const activeTurnId = activeTurnIds.get(event.agent.id);
238
+ const eventTurnId = event.turnId ?? null;
239
+ const eventUserMessages = countTurns(event.timeline) ?? 0;
240
+ const duplicate =
241
+ isStaleTurnEnd(activeTurnId, eventTurnId) ||
242
+ isInterruptEcho({
243
+ lastCanceledAt: lastCanceledAt.get(event.agent.id) ?? null,
244
+ lastCanceledUserMessages: lastCanceledUserMessages.get(event.agent.id) ?? null,
245
+ eventUserMessages,
246
+ now: Date.now(),
247
+ });
248
+ if (duplicate) {
249
+ log.info("Skipped duplicate turn-end telemetry", {
250
+ agentId: event.agent.id,
251
+ turnId: event.turnId,
252
+ activeTurnId: activeTurnId ?? null,
253
+ });
254
+ return;
255
+ }
256
+ activeTurnIds.delete(event.agent.id);
257
+ if (event.outcome.kind === "canceled") {
258
+ lastCanceledAt.set(event.agent.id, Date.now());
259
+ lastCanceledUserMessages.set(event.agent.id, eventUserMessages);
260
+ } else {
261
+ lastCanceledAt.delete(event.agent.id);
262
+ lastCanceledUserMessages.delete(event.agent.id);
263
+ }
264
+
265
+ const startTime = turnStartTimes.get(event.agent.id);
266
+ turnStartTimes.delete(event.agent.id);
267
+ const gitBefore = turnGitBefore.get(event.agent.id);
268
+ turnGitBefore.delete(event.agent.id);
269
+ const turnIndex = (turnCounters.get(event.agent.id) ?? 0) + 1;
270
+ turnCounters.set(event.agent.id, turnIndex);
271
+ void appendTurnCard(event, context, startTime, gitBefore, turnIndex);
272
+ }).catch((err) => {
273
+ log.warn("Failed to record turn telemetry", {
274
+ agentId: event.agent.id,
275
+ turnId: event.turnId,
276
+ error: err instanceof Error ? err.message : String(err),
277
+ });
278
+ });
279
+ });
280
+
281
+ return () => {
282
+ activeTurnIds.clear();
283
+ lastCanceledAt.clear();
284
+ lastCanceledUserMessages.clear();
285
+ customPillPoller.stop();
286
+ unsubscribeTurnStarted();
287
+ unsubscribeAgentCreated();
288
+ unsubscribeTurnEnded();
289
+ };
290
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@xpufx/paseo-top",
3
3
  "private": false,
4
4
  "license": "MIT",
5
- "version": "0.4.0",
5
+ "version": "0.4.1",
6
6
  "scripts": {
7
7
  "stamp": "node -e \"import('./server/vendor/paseo-plugin-helper/version.ts').then(m => m.stampVersion({ targetFile: 'shared/version.ts' }))\"",
8
8
  "typecheck": "tsc --noEmit",
@@ -21,6 +21,8 @@
21
21
  "paseo-plugin.json",
22
22
  "README.md",
23
23
  "LICENSE",
24
+ "index.client.tsx",
25
+ "index.server.ts",
24
26
  "client",
25
27
  "server",
26
28
  "shared",