paseo-smart-session 2.0.0 → 2.2.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 (66) hide show
  1. package/README.md +55 -130
  2. package/assets/LICENSE.lucide +43 -0
  3. package/assets/icon.png +0 -0
  4. package/assets/icon.svg +1 -0
  5. package/client/agents.ts +200 -0
  6. package/client/charts.tsx +277 -152
  7. package/client/chat.tsx +122 -0
  8. package/client/display.tsx +55 -33
  9. package/client/guard/index.tsx +48 -0
  10. package/client/guard/paused.tsx +164 -0
  11. package/client/guard/pill-icons.tsx +26 -0
  12. package/client/guard/pills.ts +132 -0
  13. package/client/guard/settings.tsx +114 -0
  14. package/client/guard/surfaces.ts +3 -0
  15. package/client/guard/timeline.tsx +154 -0
  16. package/client/kit.tsx +144 -0
  17. package/client/live.ts +49 -0
  18. package/client/motion.tsx +94 -0
  19. package/client/overview.tsx +373 -0
  20. package/client/pill-store.ts +77 -0
  21. package/client/pill.tsx +91 -380
  22. package/client/popover.tsx +113 -0
  23. package/client/settings.tsx +105 -0
  24. package/client/theme.ts +56 -0
  25. package/docs/media/smart-session-chat.png +0 -0
  26. package/docs/media/smart-session-flow.gif +0 -0
  27. package/docs/media/smart-session-guard.png +0 -0
  28. package/docs/media/smart-session-hero-light.png +0 -0
  29. package/docs/media/smart-session-hero.png +0 -0
  30. package/docs/media/smart-session-mobile.png +0 -0
  31. package/docs/media/smart-session-pill.png +0 -0
  32. package/docs/navigation-verification.md +6 -6
  33. package/docs/screenshots/display-settings-desktop.png +0 -0
  34. package/docs/screenshots/display-settings-mobile.png +0 -0
  35. package/docs/session-guard.md +88 -0
  36. package/index.client.tsx +52 -35
  37. package/index.server.ts +19 -11
  38. package/mcp.mjs +22 -3
  39. package/package.json +2 -1
  40. package/paseo-plugin.json +11 -0
  41. package/server/guard/client.ts +27 -0
  42. package/server/guard/engine.ts +554 -0
  43. package/server/guard/index.ts +395 -0
  44. package/server/guard/labels.ts +77 -0
  45. package/server/guard/processes.ts +147 -0
  46. package/server/guard/standalone.ts +61 -0
  47. package/server/guard/store.ts +113 -0
  48. package/server/guard/timeline.ts +24 -0
  49. package/server/guard/usage.ts +57 -0
  50. package/server/recorder.ts +2 -0
  51. package/server/settings.ts +10 -0
  52. package/server/store.ts +25 -1
  53. package/shared/blocks.ts +15 -0
  54. package/shared/chat.ts +174 -0
  55. package/shared/forecast.ts +233 -0
  56. package/shared/format.ts +47 -1
  57. package/shared/governor.ts +3 -0
  58. package/shared/guard/contracts.ts +65 -0
  59. package/shared/guard/policy.ts +128 -0
  60. package/shared/guard/presentation.ts +85 -0
  61. package/shared/guard/timeline.ts +34 -0
  62. package/shared/lifecycle.ts +18 -0
  63. package/shared/smart-session.ts +16 -0
  64. package/client/navigation.tsx +0 -14
  65. package/client/surface.tsx +0 -574
  66. package/docs/screenshots/smart-compact-handoff.png +0 -0
package/client/pill.tsx CHANGED
@@ -1,434 +1,145 @@
1
1
  /**
2
- * The smart-compact pill, on every agent's composer.
2
+ * The Smart Compact pill, on every agent's composer.
3
3
  *
4
- * Enrolment is a file on disk, which makes it invisible: there was no way to look
5
- * at a session and tell whether the governor was watching it. The pill is that
6
- * answer, in the one place the answer matters — next to the box you are about to
7
- * type into — and pressing it changes it.
4
+ * It answers two questions where they matter, next to the box you are about to type
5
+ * into: how full is this session's context, and will Smart Compact ask it to compact
6
+ * itself. The icon is a ring that fills with the context and carries a tick at the
7
+ * compact line; the label is the percentage. Pressing it opens a popover with the
8
+ * detail and the enrolment switch.
8
9
  *
9
10
  * State lives in this module rather than in each pill, because the pill and the
10
- * things that change it (its own press, the Command Center item, the surface
11
- * toggle) are all in this one client bundle. One store, one fetch, and every
12
- * mounted pill redraws together.
11
+ * things that change it (its own popover, the Command Center item, the overview and
12
+ * settings) are all in this one client bundle. One store, one fetch, and every
13
+ * mounted pill redraws together. Context occupancy comes from the shared agent
14
+ * directory, which already carries each agent's last usage, so the ring moves with
15
+ * every turn without a poll of its own.
13
16
  *
14
- * Two states, on or off, and pressing it moves between them. There is no third
15
- * state for "on but nothing will happen", because there is no longer a global
16
- * switch that can make an enrolled session inert — the master switch hides the pill
17
- * outright instead.
17
+ * With Smart Compact off there is no pill at all: there is nothing for one to say.
18
18
  */
19
19
 
20
- import type { PluginClientContext, PluginHostProps } from "@getpaseo/plugin/client";
21
- import { Icon } from "@getpaseo/plugin/client/react-native";
22
- import React, { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
23
- import { Text, View } from "react-native";
20
+ import type { PluginButtonIconProps, PluginClientContext, PluginButtonRegistration } from "@getpaseo/plugin/client";
21
+ import { useSyncExternalStore } from "react";
24
22
 
25
- import { smartCompactLabel } from "../shared/format";
26
- import { enrolmentState, setEnrolment, type EnrolmentState } from "../shared/governor";
23
+ import { contextOutlook } from "../shared/forecast";
24
+ import { enrolmentState } from "../shared/governor";
25
+ import { pillSnapshot, refreshPills, state as currentState, subscribePills } from "./pill-store";
26
+
27
+ export { refreshPills } from "./pill-store";
28
+ import { profileFor } from "../shared/thresholds";
29
+ import type { Thresholds } from "../shared/thresholds";
30
+ import type { AgentDirectory, DirectoryAgent } from "./agents";
31
+ import { Ring, toneFill } from "./charts";
32
+ import { makeContextPopover } from "./popover";
27
33
 
28
34
  /** How often the client re-reads enrolment, for changes made somewhere else. */
29
35
  const REFRESH_MS = 20_000;
30
36
 
31
- /** Pointer dwell before the tooltip opens, so passing over the pill is quiet. */
32
- const HOVER_DELAY_MS = 300;
33
-
34
- /**
35
- * The host's own pill chrome, mirrored from Paseo's `composerPillStyles`:
36
- * `spacing[3]` and `spacing[1]` of padding inside a 1px border.
37
- *
38
- * A plugin renders *inside* that padding, so a hover region left at its natural
39
- * size answers only over the glyph and stays dead across most of the pill the user
40
- * is actually pointing at. Cancelling the padding with an equal negative margin
41
- * grows the region to the pill's edge and leaves the layout exactly where it was —
42
- * the pill's measured size is unchanged, since the two cancel.
43
- */
44
- const HOST_PILL_INSET = { horizontal: 13, vertical: 5 };
45
-
46
- interface PillState {
47
- /** Both switches folded into one answer: is there a pill on the composer at all. */
48
- readonly showPill: boolean;
49
- readonly enrolled: ReadonlySet<string>;
50
- }
51
-
52
- /**
53
- * Hidden until the daemon says otherwise.
54
- *
55
- * The alternative — assume shown, then retract — flashes a pill at everyone who
56
- * turned it off, every time the app loads.
57
- */
58
- let state: PillState = { showPill: false, enrolled: new Set() };
59
-
60
- const listeners = new Set<() => void>();
61
-
62
- function subscribe(listener: () => void): () => void {
63
- listeners.add(listener);
64
- return () => {
65
- listeners.delete(listener);
37
+ /** Everything a pill shows about one agent, in one place for the icon, label and title. */
38
+ export function pillPresentation(agent: Pick<DirectoryAgent, "usedTokens" | "maxTokens"> | undefined, enrolled: boolean, thresholds: Thresholds | null) {
39
+ const known = agent !== undefined && agent.usedTokens !== null && agent.maxTokens !== null;
40
+ if (!known) {
41
+ return {
42
+ label: "Smart Compact",
43
+ title: enrolled ? "Smart Compact on · no context reading yet" : "Smart Compact off · no context reading yet",
44
+ fraction: 0, mark: null as number | null, tone: "default" as const, pct: null as number | null,
45
+ };
46
+ }
47
+ const compactAtPct = profileFor(agent.maxTokens!, thresholds ?? undefined).compact;
48
+ const outlook = contextOutlook({ usedTokens: agent.usedTokens!, maxTokens: agent.maxTokens!, compactAtPct });
49
+ const pct = Math.round(outlook.pct);
50
+ return {
51
+ label: `${pct}%`,
52
+ title: `Context ${pct}% full · ${enrolled ? `Smart Compact asks at ${compactAtPct}%` : "Smart Compact off for this session"}`,
53
+ fraction: outlook.pct / 100, mark: compactAtPct / 100, tone: outlook.tone, pct,
66
54
  };
67
55
  }
68
56
 
69
- function snapshot(): PillState {
70
- return state;
71
- }
72
-
73
- function publish(next: PillState): void {
74
- state = next;
75
- for (const listener of listeners) listener();
76
- }
77
-
78
- function enrolmentSet(agents: EnrolmentState["agents"]): ReadonlySet<string> {
79
- return new Set(agents.filter((agent) => agent.enrolled).map((agent) => agent.agentId));
80
- }
81
-
82
- /**
83
- * Re-reads enrolment and redraws every pill.
84
- *
85
- * Takes the call rather than making it: the same fetch is reachable from the client
86
- * entry, a Command Center item and the surface, and each of those holds a different
87
- * flavour of RPC caller.
88
- */
89
- export async function refreshPills(fetch: () => Promise<EnrolmentState>): Promise<void> {
90
- const next = await fetch();
91
- publish({
92
- // With the feature off there is nothing for a pill to say. Drawing a third,
93
- // "paused" state would just be a second way of spelling off.
94
- showPill: next.enabled && next.showPill,
95
- enrolled: enrolmentSet(next.agents),
96
- });
97
- }
98
-
99
- /**
100
- * One icon, and the words only when you ask for them.
101
- *
102
- * The composer track is one line shared with Paseo's own pills, and this one has
103
- * nothing to say that changes minute to minute — so it states itself in colour and
104
- * spells it out on hover. Pointer platforms get the tooltip; touch hosts never fire
105
- * these events and read the colour, or press.
106
- *
107
- * Paseo owns the pressable, the border and the spinner; this owns what is in it.
108
- */
109
- type LegacyPillProps = PluginHostProps & { workspaceId: string; agentId: string };
110
-
111
- export function AutoCompactPill({ theme, agentId }: LegacyPillProps) {
112
- const current = useSyncExternalStore(subscribe, snapshot, snapshot);
113
- const [tooltip, setTooltip] = useState(false);
114
- const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
115
-
116
- const clearHoverTimer = useCallback(() => {
117
- if (timer.current === null) return;
118
- clearTimeout(timer.current);
119
- timer.current = null;
120
- }, []);
121
- useEffect(() => clearHoverTimer, [clearHoverTimer]);
122
-
123
- const onPointerEnter = useCallback(() => {
124
- if (timer.current !== null) return;
125
- timer.current = setTimeout(() => {
126
- timer.current = null;
127
- setTooltip(true);
128
- }, HOVER_DELAY_MS);
129
- }, []);
130
-
131
- const onPointerLeave = useCallback(() => {
132
- clearHoverTimer();
133
- setTooltip(false);
134
- }, [clearHoverTimer]);
135
-
136
- const enrolled = current.enrolled.has(agentId);
137
- const color = enrolled ? theme.colors.accent : theme.colors.foregroundMuted;
138
- return (
139
- <View
140
- style={{
141
- flexDirection: "row",
142
- alignItems: "center",
143
- justifyContent: "center",
144
- // Stretch plus the cancelled padding is what makes the whole pill hoverable,
145
- // vertically as well: Paseo's pill is 32px high and this glyph is 14.
146
- alignSelf: "stretch",
147
- marginVertical: -HOST_PILL_INSET.vertical,
148
- marginHorizontal: -HOST_PILL_INSET.horizontal,
149
- paddingVertical: HOST_PILL_INSET.vertical,
150
- paddingHorizontal: HOST_PILL_INSET.horizontal,
151
- }}
152
- onPointerEnter={onPointerEnter}
153
- onPointerLeave={onPointerLeave}
154
- >
155
- <Icon name="FoldVertical" size={14} color={color} />
156
- {tooltip ? (
157
- // Drawn above the pill rather than beside it: the track bar is one line
158
- // high, and widening the pill on hover would shove its neighbours along.
159
- <View
160
- style={{
161
- position: "absolute",
162
- bottom: "100%",
163
- left: 0,
164
- marginBottom: 8,
165
- gap: 2,
166
- paddingVertical: 6,
167
- paddingHorizontal: 10,
168
- borderRadius: 8,
169
- borderWidth: 1,
170
- borderColor: theme.colors.border,
171
- backgroundColor: theme.colors.surface1,
172
- }}
173
- >
174
- <Text numberOfLines={1} style={{ color: theme.colors.foreground, fontSize: 13 }}>
175
- {smartCompactLabel({ enrolled })}
176
- </Text>
177
- <Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
178
- {enrolled ? "Press to take this session out" : "Press to enrol this session"}
179
- </Text>
180
- </View>
181
- ) : null}
182
- </View>
183
- );
184
- }
185
-
186
- /**
187
- * The v0.8 button-descriptor icon. The host owns its tooltip and interaction;
188
- * this component only paints the current state inside the icon slot.
189
- */
190
- function AutoCompactIcon({
191
- theme,
192
- agentId,
193
- size,
194
- }: PluginHostProps & { agentId: string; size: number; color: string }) {
195
- const current = useSyncExternalStore(subscribe, snapshot, snapshot);
196
- const color = current.enrolled.has(agentId) ? theme.colors.accent : theme.colors.foregroundMuted;
197
- return <Icon name="FoldVertical" size={size} color={color} />;
198
- }
199
-
200
- /** The lean shapes this needs from the agent directory, which carries far more. */
201
- interface ListedAgent {
202
- readonly id?: string;
203
- readonly workspaceId?: string | null;
204
- }
205
-
206
- interface AgentUpdate {
207
- readonly kind: "remove" | "upsert";
208
- readonly agentId?: string;
209
- readonly agent?: ListedAgent;
210
- }
211
-
212
- interface AgentListing {
213
- readonly entries?: readonly { readonly agent?: ListedAgent }[];
214
- readonly subscription?: {
215
- subscribe(observer: {
216
- snapshot(listing: AgentListing): void;
217
- update(message: { readonly type?: string; readonly payload?: AgentUpdate }): void;
218
- }): () => void;
219
- release(): Promise<void>;
57
+ /** One live icon per pill: the ring, muted while the session is not enrolled. */
58
+ function makeIcon(agents: AgentDirectory) {
59
+ return function ContextRingIcon(props: PluginButtonIconProps) {
60
+ const current = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
61
+ useSyncExternalStore(agents.subscribe, agents.version, agents.version);
62
+ const agentId = props.context === "agent" ? props.agentId : "";
63
+ const enrolled = current.enrolled.has(agentId);
64
+ const view = pillPresentation(agents.current().get(agentId), enrolled, current.thresholds);
65
+ const c = props.theme.colors;
66
+ const color = view.tone !== "default" ? toneFill(c, view.tone) : enrolled ? c.foreground : c.foregroundMuted;
67
+ return <Ring size={props.size} color={color} fraction={view.fraction} mark={enrolled ? view.mark : null} />;
220
68
  };
221
69
  }
222
70
 
223
- /**
224
- * The published 0.8 descriptor API returns this handle. The 0.8.0-beta.1 npm
225
- * package and app still return the legacy remover function, so registrations
226
- * carry both input shapes and this adapter accepts either result. Remove these
227
- * legacy fields once the beta client and SDK catch up with the deployed docs.
228
- */
229
- interface PillRegistration {
230
- update(patch: { title?: string; label?: string; visible?: boolean; disabled?: boolean }): void;
231
- remove(): void;
232
- }
233
-
234
- type PillHandle = (() => void) | PillRegistration;
235
-
236
- function removePill(handle: PillHandle): void {
237
- if (typeof handle === "function") handle();
238
- else handle.remove();
239
- }
240
-
241
- function updatePill(handle: PillHandle, title: string): void {
242
- if (typeof handle !== "function") handle.update({ title });
243
- }
244
-
245
- export function contributeClient(client: PluginClientContext) {
71
+ export function contributeClient(client: PluginClientContext, agents: AgentDirectory) {
246
72
  /** Every agent this app knows of, and its pill registration while one is up. */
247
- const tracked = new Map<string, { workspaceId: string; handle: PillHandle | null }>();
248
- const lifetime = new AbortController();
249
- let stopped = false;
250
- let unsubscribeOwnedObservation: (() => void) | null = null;
251
- let releaseAgentObservation: (() => Promise<void>) | null = null;
73
+ const tracked = new Map<string, { workspaceId: string; handle: PluginButtonRegistration | null; signature: string }>();
74
+ const Icon = makeIcon(agents);
75
+ const Content = makeContextPopover(client, agents);
252
76
 
253
- function register(agentId: string, entry: { workspaceId: string; handle: PillHandle | null }): void {
254
- const toggle = async () => {
255
- const { agent } = await client.rpc(setEnrolment, {
256
- agentId,
257
- enrolled: !snapshot().enrolled.has(agentId),
258
- });
259
- const enrolled = new Set(snapshot().enrolled);
260
- if (agent.enrolled) enrolled.add(agent.agentId);
261
- else enrolled.delete(agent.agentId);
262
- publish({ ...snapshot(), enrolled });
263
- };
264
- const title = smartCompactLabel({ enrolled: snapshot().enrolled.has(agentId) });
265
- const addComposerPill = client.addComposerPill as unknown as (contribution: {
266
- id: string;
267
- workspaceId: string;
268
- agentId: string;
269
- button: {
270
- title: string;
271
- icon: typeof AutoCompactIcon;
272
- label: string;
273
- behavior: { kind: "action"; onPress: () => Promise<void> };
274
- };
275
- title: string;
276
- Component: typeof AutoCompactPill;
277
- onPress: () => Promise<void>;
278
- }) => PillHandle;
279
- entry.handle = addComposerPill({
77
+ function describe(agentId: string) {
78
+ const view = pillPresentation(agents.current().get(agentId), currentState().enrolled.has(agentId), currentState().thresholds);
79
+ return { label: view.label, title: view.title };
80
+ }
81
+
82
+ function register(agentId: string, entry: { workspaceId: string; handle: PluginButtonRegistration | null; signature: string }): void {
83
+ const { label, title } = describe(agentId);
84
+ entry.signature = `${label}|${title}`;
85
+ entry.handle = client.addComposerPill({
280
86
  id: "smart-compact",
281
87
  workspaceId: entry.workspaceId,
282
88
  agentId,
283
- button: {
284
- title,
285
- icon: AutoCompactIcon,
286
- label: "Smart Compact",
287
- behavior: { kind: "action", onPress: toggle },
288
- },
289
- // 0.8.0-beta.1 compatibility; ignored by the documented descriptor host.
290
- title,
291
- Component: AutoCompactPill,
292
- onPress: toggle,
89
+ button: { title, label, icon: Icon, behavior: { kind: "popover", Content } },
293
90
  });
294
91
  }
295
92
 
296
93
  /** Brings the registrations in line with the store, whichever way it moved. */
297
94
  function sync(): void {
298
95
  for (const [agentId, entry] of tracked) {
299
- if (state.showPill && entry.handle === null) register(agentId, entry);
300
- else if (!state.showPill && entry.handle !== null) {
301
- removePill(entry.handle);
96
+ if (currentState().showPill && entry.handle === null) register(agentId, entry);
97
+ else if (!currentState().showPill && entry.handle !== null) {
98
+ entry.handle.remove();
302
99
  entry.handle = null;
303
100
  } else if (entry.handle !== null) {
304
- updatePill(entry.handle, smartCompactLabel({ enrolled: state.enrolled.has(agentId) }));
101
+ const { label, title } = describe(agentId);
102
+ const signature = `${label}|${title}`;
103
+ // Only a changed label is sent: a fresh behaviour would remount an open popover.
104
+ if (signature !== entry.signature) {
105
+ entry.signature = signature;
106
+ entry.handle.update({ label, title });
107
+ }
305
108
  }
306
109
  }
307
110
  }
308
111
 
309
- function track(agentId: string, workspaceId: string): void {
310
- const existing = tracked.get(agentId);
311
- if (existing !== undefined && existing.workspaceId === workspaceId) return;
312
- // A pill is pinned to one workspace, so an agent that moved needs a new one.
313
- if (existing?.handle !== null && existing?.handle !== undefined) removePill(existing.handle);
314
- tracked.set(agentId, { workspaceId, handle: null });
315
- sync();
316
- }
317
-
318
112
  function forget(agentId: string): void {
319
- const handle = tracked.get(agentId)?.handle;
320
- if (handle !== null && handle !== undefined) removePill(handle);
113
+ tracked.get(agentId)?.handle?.remove();
321
114
  tracked.delete(agentId);
322
115
  }
323
116
 
324
- function applyAgentUpdate(update: AgentUpdate): void {
325
- if (update.kind === "remove") {
326
- if (typeof update.agentId === "string") forget(update.agentId);
327
- return;
328
- }
329
- const agent = update.agent;
330
- if (
331
- typeof agent?.id !== "string" ||
332
- typeof agent.workspaceId !== "string" ||
333
- agent.workspaceId === ""
334
- ) return;
335
- track(agent.id, agent.workspaceId);
336
- }
337
-
338
- function trackAgents(entries: AgentListing["entries"]): Set<string> {
339
- const present = new Set<string>();
340
- for (const entry of entries ?? []) {
341
- const agent = entry.agent;
342
- if (
343
- typeof agent?.id !== "string" ||
344
- typeof agent.workspaceId !== "string" ||
345
- agent.workspaceId === ""
346
- ) continue;
347
- present.add(agent.id);
348
- track(agent.id, agent.workspaceId);
117
+ /** Follows the shared directory: new agents gain a pill, moved or removed ones lose theirs. */
118
+ function followDirectory(): void {
119
+ const current = agents.current();
120
+ for (const [agentId, entry] of [...tracked]) {
121
+ if (current.get(agentId)?.workspaceId !== entry.workspaceId) forget(agentId);
349
122
  }
350
- return present;
351
- }
352
-
353
- /** A restored observation replaces its directory snapshot, including removals. */
354
- function replaceAgents(entries: AgentListing["entries"]): void {
355
- const present = trackAgents(entries);
356
- for (const agentId of [...tracked.keys()]) {
357
- if (!present.has(agentId)) forget(agentId);
123
+ for (const [agentId, agent] of current) {
124
+ // A pill is pinned to one workspace, so an agent that moved needs a new one.
125
+ if (!tracked.has(agentId)) tracked.set(agentId, { workspaceId: agent.workspaceId, handle: null, signature: "" });
358
126
  }
127
+ sync();
359
128
  }
360
129
 
361
130
  const refresh = () => refreshPills(() => client.rpc(enrolmentState, {}));
362
131
 
363
- const unsubscribeStore = subscribe(sync);
364
-
365
- // v0.8 returns only the listing and emits its subscribed updates through this
366
- // local listener. Keep it attached until a newer runtime gives us the owned
367
- // observation whose snapshots can reconcile the whole directory on reconnect.
368
- let unsubscribeLegacyAgents: (() => void) | null = client.paseo.agents.subscribe((update) => {
369
- applyAgentUpdate(update as AgentUpdate);
370
- });
371
-
372
- // The subscription only carries agents that report in from now on, and an idle
373
- // one may not do that for hours.
374
- void (async () => {
375
- try {
376
- // `agents.subscribe()` listens only to observations already owned by
377
- // this API instance. A plain list stopped receiving newly created agents
378
- // in Paseo 0.9, which made both plugin pills disappear on new sessions.
379
- const listAgents = client.paseo.agents.list as unknown as (options: {
380
- subscribe: Record<string, never>;
381
- signal: AbortSignal;
382
- }) => Promise<AgentListing>;
383
- const listing = await listAgents({ subscribe: {}, signal: lifetime.signal });
384
- const release = listing.subscription
385
- ? () => listing.subscription!.release()
386
- : null;
387
- if (stopped) {
388
- await release?.();
389
- return;
390
- }
391
- releaseAgentObservation = release;
392
- // The legacy listener was already live while this snapshot was in flight,
393
- // so bootstrap must be additive or it can erase a just-created agent.
394
- trackAgents(listing.entries);
395
- if (listing.subscription) {
396
- unsubscribeOwnedObservation = listing.subscription.subscribe({
397
- snapshot(snapshot) {
398
- if (!stopped) replaceAgents(snapshot.entries);
399
- },
400
- update(message) {
401
- if (!stopped && message.type === "agent_update" && message.payload) {
402
- applyAgentUpdate(message.payload);
403
- }
404
- },
405
- });
406
- unsubscribeLegacyAgents?.();
407
- unsubscribeLegacyAgents = null;
408
- }
409
- } catch {
410
- const release = releaseAgentObservation;
411
- releaseAgentObservation = null;
412
- await release?.().catch(() => undefined);
413
- // Not fatal to the rest of the client contribution; a reload can retry.
414
- }
415
- })();
132
+ const unsubscribeStore = subscribePills(sync);
133
+ const unsubscribeAgents = agents.subscribe(followDirectory);
134
+ followDirectory();
416
135
 
417
136
  void refresh().catch(() => undefined);
418
137
  const timer = setInterval(() => void refresh().catch(() => undefined), REFRESH_MS);
419
138
 
420
- return async () => {
421
- stopped = true;
422
- lifetime.abort();
139
+ return () => {
423
140
  clearInterval(timer);
424
- unsubscribeLegacyAgents?.();
425
- unsubscribeLegacyAgents = null;
426
- unsubscribeOwnedObservation?.();
427
- unsubscribeOwnedObservation = null;
141
+ unsubscribeAgents();
428
142
  unsubscribeStore();
429
143
  for (const agentId of [...tracked.keys()]) forget(agentId);
430
- const release = releaseAgentObservation;
431
- releaseAgentObservation = null;
432
- await release?.().catch(() => undefined);
433
144
  };
434
145
  }
@@ -0,0 +1,113 @@
1
+ import { useState, useSyncExternalStore } from "react";
2
+ import { Text, View } from "react-native";
3
+ import { useRpc, type PluginButtonContentProps, type PluginClientContext } from "@getpaseo/plugin/client";
4
+ import { useQuery } from "@tanstack/react-query";
5
+
6
+ import { formatCompactTokens, formatDuration, formatRelative } from "../shared/format";
7
+ import { contextOutlook } from "../shared/forecast";
8
+ import { listCompactions, setEnrolment, type CompactionRequest } from "../shared/governor";
9
+ import { contextStatus } from "../shared/smart-session";
10
+ import { profileFor } from "../shared/thresholds";
11
+ import type { AgentDirectory } from "./agents";
12
+ import { Meter, Ring, toneFill } from "./charts";
13
+ import { Switch, TextLink, errorText, text, useNow } from "./kit";
14
+ import { noteEnrolment, pillSnapshot, subscribePills } from "./pill-store";
15
+ import { tabular, toneColor } from "./theme";
16
+
17
+ /** What the last compaction did, in one line. */
18
+ function compactionLine(item: CompactionRequest | undefined, now: number): string {
19
+ if (item === undefined) return "None yet";
20
+ if (item.state === "pending" || item.state === "sending") return "Queued · sent when this turn ends";
21
+ if (item.state === "failed") return `Failed ${formatRelative(item.settledAt ?? item.createdAt, now)}`;
22
+ if (item.state === "cancelled") return `Cancelled ${formatRelative(item.settledAt ?? item.createdAt, now)}`;
23
+ const when = formatRelative(item.settledAt ?? item.createdAt, now);
24
+ return item.preTokens !== null && item.postTokens !== null ? `${when} · ${formatCompactTokens(item.preTokens)} → ${formatCompactTokens(item.postTokens)}` : when;
25
+ }
26
+
27
+ /** The pill's popover: this session's context, its compact line, task state and enrolment. */
28
+ export function makeContextPopover(client: PluginClientContext, agents: AgentDirectory) {
29
+ return function ContextPopover({ theme, layout, close, ...rest }: PluginButtonContentProps) {
30
+ const agentId = rest.context === "agent" ? rest.agentId : "";
31
+ const c = theme.colors;
32
+ const t = text(c);
33
+ const now = useNow(30_000);
34
+ const pills = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
35
+ useSyncExternalStore(agents.subscribe, agents.version, agents.version);
36
+ const readContext = useRpc(contextStatus);
37
+ const readCompactions = useRpc(listCompactions);
38
+ const writeEnrolment = useRpc(setEnrolment);
39
+ const [saving, setSaving] = useState(false);
40
+ const [problem, setProblem] = useState<string | null>(null);
41
+ const detail = useQuery({ queryKey: ["smart-session", "context", agentId], queryFn: () => readContext({ agentId }), refetchInterval: 30_000 });
42
+ const compactions = useQuery({ queryKey: ["smart-session", "compactions", agentId], queryFn: () => readCompactions({ agentId }), refetchInterval: 30_000 });
43
+
44
+ const agent = agents.current().get(agentId);
45
+ const row = detail.data?.agents[0];
46
+ const used = agent?.usedTokens ?? row?.usedTokens ?? null;
47
+ const max = agent?.maxTokens ?? row?.maxTokens ?? null;
48
+ const enrolled = pills.enrolled.has(agentId);
49
+ const compact = layout.compact;
50
+
51
+ async function toggle(next: boolean) {
52
+ setSaving(true); setProblem(null);
53
+ try {
54
+ const { agent: saved } = await writeEnrolment({ agentId, enrolled: next });
55
+ noteEnrolment(saved.agentId, saved.enrolled);
56
+ } catch (error) {
57
+ setProblem(errorText(error));
58
+ } finally {
59
+ setSaving(false);
60
+ }
61
+ }
62
+
63
+ const head = (() => {
64
+ if (used === null || max === null) return null;
65
+ const compactAtPct = row?.compactAtPct ?? profileFor(max, pills.thresholds ?? undefined).compact;
66
+ return { compactAtPct, outlook: contextOutlook({ usedTokens: used, maxTokens: max, compactAtPct, growthTokensPerHour: row?.growthTokensPerHour ?? null }) };
67
+ })();
68
+ const sentence = head === null
69
+ ? { line: "No context reading yet. A session reports one after its first turn.", tone: "default" as const }
70
+ : head.outlook.past
71
+ ? { line: enrolled ? "Past its compact line. Smart Compact asks it at the end of a turn whether to compact." : "Past its compact line, but not enrolled, so nothing will ask it.", tone: head.outlook.tone }
72
+ : head.outlook.untilCompactMs !== null
73
+ ? { line: `Reaches its compact line in about ${formatDuration(head.outlook.untilCompactMs)} at +${formatCompactTokens(row!.growthTokensPerHour!)}/h.`, tone: "default" as const }
74
+ : { line: "Below its compact line and not growing right now.", tone: "default" as const };
75
+ const latest = compactions.data?.items[0];
76
+ const state = row?.stateWrittenAt ?? null;
77
+ const facts: [string, string][] = [
78
+ ["Task state", state === null ? (row === undefined ? "…" : "Not written yet") : `Saved ${formatRelative(state, now)}`],
79
+ ["Last compaction", compactions.data === undefined ? "…" : compactionLine(latest, now)],
80
+ ];
81
+ const ringColor = head === null ? c.foregroundMuted : head.outlook.tone !== "default" ? toneFill(c, head.outlook.tone) : enrolled ? c.foreground : c.foregroundMuted;
82
+
83
+ return <View style={{ width: compact ? "100%" : 340, padding: compact ? 16 : 14, gap: 12 }}>
84
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
85
+ <Ring size={40} color={ringColor} fraction={head === null ? 0 : head.outlook.pct / 100} mark={head === null ? null : head.compactAtPct / 100} />
86
+ <View style={{ flex: 1, gap: 1 }}>
87
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
88
+ <Text style={{ color: c.foreground, fontSize: 22, lineHeight: 28, fontWeight: "600" }}>{head === null ? "—" : `${Math.round(head.outlook.pct)}%`}</Text>
89
+ <Text style={t.meta}>{max === null ? "context" : `of ${formatCompactTokens(max)} context`}</Text>
90
+ </View>
91
+ <Text style={t.meta}>{head === null || used === null ? "Waiting for a reading" : `${formatCompactTokens(used)} tokens · compacts at ${head.compactAtPct}% (${formatCompactTokens(head.outlook.compactTokens)})`}</Text>
92
+ </View>
93
+ </View>
94
+ {head === null ? null : <Meter theme={theme} pct={head.outlook.pct} tone={head.outlook.tone} mark={head.compactAtPct} height={6} label={`Context ${Math.round(head.outlook.pct)}% full, compacts at ${head.compactAtPct}%`} />}
95
+ <Text style={{ ...t.body, color: sentence.tone === "default" ? c.foreground : toneColor(c, sentence.tone) }}>{sentence.line}</Text>
96
+ <View style={{ gap: 6, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border }}>
97
+ {facts.map(([label, value]) => <View key={label} style={{ flexDirection: "row", gap: 12 }}>
98
+ <Text style={{ ...t.meta, width: 112 }}>{label}</Text>
99
+ <Text style={{ ...t.meta, flex: 1, color: c.foreground, ...tabular }}>{value}</Text>
100
+ </View>)}
101
+ </View>
102
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border }}>
103
+ <View style={{ flex: 1, gap: 2 }}>
104
+ <Text style={t.label}>Smart Compact for this session</Text>
105
+ <Text style={t.meta}>{enrolled ? `Asked to compact itself once past ${head?.compactAtPct ?? "its"}%${head === null ? " line" : ""}. Never forced.` : "Never asked to compact. The agent can still request it."}</Text>
106
+ </View>
107
+ <Switch theme={theme} label="Smart Compact for this session" hideLabel value={enrolled} disabled={saving} compact={compact} onChange={(next) => void toggle(next)} />
108
+ </View>
109
+ {problem ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{problem}</Text> : null}
110
+ <TextLink theme={theme} compact={compact} label="Plan usage and every session" onPress={() => { close(); client.openScreen({ screenId: "overview" }); }} />
111
+ </View>;
112
+ };
113
+ }