paseo-smart-session 1.2.6 → 2.1.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 (47) hide show
  1. package/README.md +45 -134
  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 +178 -0
  6. package/client/button-visibility.ts +32 -0
  7. package/client/display.tsx +102 -0
  8. package/client/guard/index.tsx +50 -0
  9. package/client/guard/paused.tsx +77 -0
  10. package/client/guard/pill-icons.tsx +26 -0
  11. package/client/guard/pills.ts +122 -0
  12. package/client/guard/settings.tsx +114 -0
  13. package/client/guard/surfaces.ts +2 -0
  14. package/client/guard/timeline.tsx +94 -0
  15. package/client/navigation.tsx +14 -0
  16. package/client/pill.tsx +12 -124
  17. package/client/sidebar-visibility.ts +21 -0
  18. package/client/surface.tsx +88 -3
  19. package/docs/navigation-verification.md +11 -0
  20. package/docs/screenshots/display-settings-desktop.png +0 -0
  21. package/docs/screenshots/display-settings-mobile.png +0 -0
  22. package/docs/session-guard.md +88 -0
  23. package/index.client.tsx +15 -4
  24. package/index.server.ts +9 -5
  25. package/mcp.mjs +32 -3
  26. package/package.json +6 -5
  27. package/paseo-plugin.json +2 -1
  28. package/server/guard/client.ts +27 -0
  29. package/server/guard/engine.ts +554 -0
  30. package/server/guard/index.ts +395 -0
  31. package/server/guard/labels.ts +77 -0
  32. package/server/guard/processes.ts +147 -0
  33. package/server/guard/standalone.ts +61 -0
  34. package/server/guard/store.ts +113 -0
  35. package/server/guard/timeline.ts +24 -0
  36. package/server/guard/usage.ts +57 -0
  37. package/server/install.ts +7 -7
  38. package/server/recorder.ts +2 -0
  39. package/server/session-mcp.ts +26 -0
  40. package/server/store.ts +25 -1
  41. package/shared/display.ts +11 -0
  42. package/shared/guard/contracts.ts +65 -0
  43. package/shared/guard/policy.ts +128 -0
  44. package/shared/guard/presentation.ts +85 -0
  45. package/shared/guard/timeline.ts +34 -0
  46. package/shared/lifecycle.ts +18 -0
  47. package/shared/smart-session.ts +1 -1
package/README.md CHANGED
@@ -2,176 +2,87 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/paseo-smart-session?style=for-the-badge&color=cb3837)](https://www.npmjs.com/package/paseo-smart-session)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/paseo-smart-session?style=for-the-badge&color=cb3837)](https://www.npmjs.com/package/paseo-smart-session)
5
- [![Paseo](https://img.shields.io/badge/Paseo-%E2%89%A5%200.8.0-8A63D2?style=for-the-badge)](https://paseo.sh)
5
+ [![Paseo](https://img.shields.io/badge/Paseo-%E2%89%A5%200.14.0-8A63D2?style=for-the-badge)](https://paseo.sh)
6
6
  [![License](https://img.shields.io/github/license/tomgrin10/paseo-smart-session?style=for-the-badge&color=2563eb)](LICENSE)
7
7
 
8
- Give Paseo agents a safe way to manage long-running work.
9
-
10
- Smart Session lets an agent see how full its context is, save durable task state, and compact itself when it is ready. It also records session and plan-usage statistics, so you can understand where tokens are going and when limits reset.
8
+ Let agents manage their own long-running work. Smart Session shows an agent how full its context is, lets it save durable task state, and lets it compact itself at a safe stopping point. It also keeps a history of context, plan usage and token spend.
11
9
 
12
10
  ![A Smart Session compaction handoff: the requested compact preserves the task state, then the resumed session is told to re-read its state file and continue](docs/screenshots/smart-compact-handoff.png)
13
11
 
14
- ## What it does
15
-
16
- ### Lets agents compact themselves
17
-
18
- An agent can check how much context it has used, checkpoint the work that must survive a reset, and request compaction when it reaches a safe stopping point. The agent also decides whether another turn should start afterwards and can write the exact follow-up message. A completed task can compact and stop; work in progress can resume from its saved state.
19
-
20
- The saved state captures the goal, the current step, decisions already made, and approaches that have already failed. That prevents a resumed agent from redoing work or asking a person how to continue.
21
-
22
- Smart Session never chooses to compact an agent by itself. It can let an agent know that context is filling, but the agent decides whether to checkpoint, compact, or defer.
23
-
24
- ### Shows session and plan usage
25
-
26
- Smart Session keeps a local history of:
27
-
28
- - Context-window usage for each agent session
29
- - Claude plan usage, burn rate, and reset times
30
- - Token spend by model, workspace, and main agent versus subagent
31
- - Compaction requests and their before/after context sizes
32
-
33
- That history remains useful after an individual session ends or a provider cache changes.
34
-
35
12
  ## Install
36
13
 
37
- Install from npm on Paseo 0.9.0 or newer:
38
-
39
14
  ```sh
40
- paseo plugin install npm:paseo-smart-session@1.2.6
15
+ paseo plugin install paseo-smart-session
41
16
  ```
42
17
 
43
- Paseo 0.8 can install the same plugin from Git:
18
+ On first load it registers its Claude Code hooks, touching only entries that point at its own scripts. Paseo attaches its MCP server when an interactive session opens.
44
19
 
45
- ```sh
46
- paseo plugin add tomgrin10/paseo-smart-session --ref v1.2.6
47
- ```
48
-
49
- ```sh
50
- paseo plugin ls # confirm it is running
51
- paseo plugin update smart-session
52
- paseo plugin remove smart-session
53
- ```
54
-
55
- On first load, Smart Session registers its Claude Code hooks and MCP server. It only manages entries that point at its own scripts; other Claude Code configuration is left alone.
56
-
57
- ## How an agent uses it
20
+ ## Use
58
21
 
59
- Smart Session exposes these MCP tools to Paseo agents:
22
+ Agents get these tools:
60
23
 
61
24
  | Tool | What it does |
62
25
  | --- | --- |
63
- | `context_status` | Shows current context usage, filling rate, and relevant thresholds. |
64
- | `checkpoint` | Writes durable task state for the current session. |
65
- | `request_compaction` | Queues compaction and chooses whether and how to continue afterwards. |
66
- | `defer_compaction` | Defers a compaction request and records why. |
67
- | `budget_status` | Shows plan usage, burn rate, and reset times. |
68
-
69
- The usual flow is:
70
-
71
- ```text
72
- context_status → checkpoint → request_compaction → stop, or re-read state and continue
73
- ```
74
-
75
- `request_compaction` refuses when the state file is missing or stale. The agent can checkpoint and request again in the same turn.
76
-
77
- ## Smart Compact
26
+ | `context_status` | Current context usage, fill rate and thresholds |
27
+ | `checkpoint` | Save durable task state for this session |
28
+ | `request_compaction` | Queue `/compact`, and choose whether and how to continue afterwards |
29
+ | `defer_compaction` | Put off a compaction and record why |
30
+ | `budget_status` | Plan usage, burn rate and reset times, and whether Session Guard will pause this session |
78
31
 
79
- Smart Compact is the session-control feature. As a session fills, hooks can provide context notices at useful thresholds. When the compact threshold is reached at the end of a turn, the agent is asked whether it wants to compact.
32
+ The usual flow is `context_status → checkpoint → request_compaction`, then stop or re-read the state and continue. Saved state holds the goal, current step, decisions made and approaches that failed, so a resumed agent neither redoes work nor asks how to continue.
80
33
 
81
- There is no forced compaction. An agent can compact immediately, defer with a reason, or keep working. A request is delivered only when the agent is idle and its state file is current.
34
+ The composer pill shows whether a session is enrolled in **Smart Compact**.
82
35
 
83
- Every request sends the `/compact` command. The agent can also set:
36
+ ## How Smart Compact works
84
37
 
85
- - `continue_after_compaction: false` to finish without starting another turn.
86
- - `continue_after_compaction: true` to start another turn after compaction.
87
- - `continue_message` to choose the exact follow-up; when omitted, the default tells the agent to re-read the state file and continue from its **Current step**.
38
+ As a session fills, hooks send context notices. At the compact threshold, the agent is asked at the end of its turn whether it wants to compact. Nothing is forced: the agent can compact, defer with a reason, or keep working. A request is delivered only when the agent is idle and its state file is current; `request_compaction` refuses a missing or stale one.
88
39
 
89
- Omitting the new options preserves the earlier behavior: Smart Session continues with the default state-aware prompt. The durable state wins if it disagrees with the automatic summary. A `/compact` entered by a person never receives a plugin continuation.
40
+ After compaction, the agent chooses to stop (`continue_after_compaction: false`), continue with the default prompt to re-read its state, or continue with its own `continue_message`. A `/compact` typed by a person never gets a plugin continuation.
90
41
 
91
- ### The composer pill
92
-
93
- The composer pill shows whether the current session is enrolled:
94
-
95
- | State | Meaning |
96
- | --- | --- |
97
- | **Smart Compact on** | The session can receive context notices and request compaction. |
98
- | **Smart Compact off** | The session is not enrolled. |
99
-
100
- If Smart Compact is disabled globally, the pill is not shown.
101
-
102
- ## Settings
103
-
104
- | Setting | Default | Description |
42
+ | Setting | Default | |
105
43
  | --- | --- | --- |
106
- | **Smart Compact** | On | Enables context notices and agent-requested compaction. |
107
- | **Large-window compact threshold** | 30% | Sets when Smart Compact asks on context windows of 400k tokens or more. |
108
- | **Small-window compact threshold** | 85% | Sets when Smart Compact asks on smaller context windows. |
109
- | **Enrol sessions automatically** | On | Enrols a session after it writes task state. |
110
- | **Show the pill on every agent** | On | Shows the Smart Compact control in the composer. |
111
- | **Register the hooks with Claude Code** | On | Keeps the context and compaction hooks installed. |
44
+ | Smart Compact | On | Context notices and agent-requested compaction |
45
+ | Large-window compact threshold | 30% | Context windows of 400k tokens or more |
46
+ | Small-window compact threshold | 85% | Smaller context windows |
47
+ | Enrol sessions automatically | On | Enrol a session once it writes task state |
48
+ | Show the pill on every agent | On | Composer pill visibility |
49
+ | Register the hooks with Claude Code | On | Keep the context and compaction hooks installed |
112
50
 
113
- The remaining settings apply only while Smart Compact is enabled. Thresholds accept whole percentages from 1% to 99%; lowering one also moves its earlier notice bands down when needed.
51
+ ## Session Guard
114
52
 
115
- ## Usage history and state
53
+ Session Guard pauses agents at the provider's session limit and continues the same conversations after the reset, even with the app closed. It is the [Session Guard](https://github.com/tomgrin10/paseo-session-guard) plugin folded into Smart Session, and it is **off by default**: turn it on from the overview or **Settings → Plugins → Smart Session → Session Guard**.
116
54
 
117
- Smart Session stores its data under:
55
+ On Linux, Claude and Codex sessions pause in place together with their native subagents; elsewhere the turn is interrupted and one continuation is sent after the reset. Each session gets a pill to opt out or release it, and each pause gets a timeline card with **Resume now** and **Dismiss pause**. For Claude, a reset is confirmed from whichever is fresher, Paseo's cached usage or Smart Session's own recorder.
118
56
 
119
- ```text
120
- $PASEO_HOME/plugin-data/smart-session/
121
- ```
122
-
123
- | File | Contains |
124
- | --- | --- |
125
- | `usage-YYYY-MM.jsonl` | Plan-usage observations and reset times. |
126
- | `context-YYYY-MM.jsonl` | Context-window usage changes. |
127
- | `compactions.json` | Compaction requests, continuation choices, and context sizes. |
128
- | `spend-index.json` | Hourly token spend by model, workspace, and agent type. |
129
- | `state/<agentId>.md` | Durable task state for one agent. |
130
- | `settings.json`, `enrolment.json` | Plugin settings and per-session enrolment. |
57
+ While the standalone plugin is enabled, Smart Session leaves session protection to it, so a session is never paused twice. Disable or uninstall Session Guard to hand over; per-session choices and waiting pauses in `$PASEO_HOME/plugin-data/session-guard/` carry over. See the [Session Guard guide](docs/session-guard.md) for settings and limits.
131
58
 
132
- Usage and context observations are append-only. A damaged line is skipped rather than putting the rest of the history at risk. Existing compaction rows are read as “continue with the default prompt,” so upgrading requires no data migration.
59
+ ## Data and access
133
60
 
134
- ## Password-protected daemons
61
+ Everything lives in `$PASEO_HOME/plugin-data/smart-session/`: monthly usage and context logs, compaction records, an hourly spend index by model, workspace and agent type, and one `state/<agentId>.md` per agent. Logs are append-only, and a damaged line is skipped rather than risking the rest.
135
62
 
136
- When the Paseo daemon requires a password, Smart Session resolves it in this order:
63
+ Paseo plugins run trusted, unsandboxed code on the daemon host. Smart Session reads local Claude Code usage data and talks only to the local daemon. For password-protected daemons it reads `PASEO_PASSWORD`, then `PASEO_PASSWORD_FILE`, then `~/paseo-hub/secrets/daemon-password`, and never writes or logs it.
137
64
 
138
- 1. `PASEO_PASSWORD`
139
- 2. The file named by `PASEO_PASSWORD_FILE`
140
- 3. `~/paseo-hub/secrets/daemon-password`
141
-
142
- The secret is used only to connect to the daemon. It is never written or logged.
143
-
144
- ## Develop locally
65
+ ## Development
145
66
 
146
67
  ```sh
147
- git clone https://github.com/tomgrin10/paseo-smart-session.git
148
- cd paseo-smart-session
149
68
  npm ci
150
69
  npm run verify
151
- paseo plugin install "$PWD"
70
+ paseo plugin install .
71
+ paseo plugin reload smart-session # after changes
152
72
  ```
153
73
 
154
- After editing:
155
-
156
- ```sh
157
- npm run verify
158
- paseo plugin reload smart-session
159
- ```
160
-
161
- Reloading the plugin is enough. Do not restart the Paseo daemon: that interrupts every running agent.
162
-
163
- ## Data and privacy
164
-
165
- Smart Session reads local Claude Code usage information and communicates with the local Paseo daemon. Its usage history, settings, and task state stay under `$PASEO_HOME`. Apart from narrowly maintaining its own Claude Code hook and MCP entries, it does not modify user configuration.
166
-
167
- ## More Paseo plugins
168
-
169
- Also available from [Tom Gringauz](https://github.com/tomgrin10):
170
-
171
- - [Defer](https://www.npmjs.com/package/paseo-defer) — Schedule messages to agents for later delivery.
172
- - [Graphite](https://www.npmjs.com/package/paseo-graphite) — Monitor Graphite stacks and PR action state.
173
- - [Vitals](https://www.npmjs.com/package/paseo-vitals) — Host, Paseo, agent, and Docker health in one dashboard.
174
-
175
74
  ## License
176
75
 
177
76
  [MIT](LICENSE) © 2026 Tom Gringauz.
77
+
78
+ ## Other plugins
79
+
80
+ - [Defer](https://github.com/tomgrin10/paseo-defer) — Schedule messages for later delivery.
81
+ - [Follow-up](https://github.com/tomgrin10/paseo-follow-up) — Track workspace PRs, remaining tasks and completion.
82
+ - [Graphite](https://github.com/tomgrin10/paseo-graphite) — Graphite stacks and PR status.
83
+ - [Send to Paseo](https://github.com/tomgrin10/send-to-paseo) — Send GitHub and Graphite PRs to Paseo from Chrome.
84
+ - [Session Guard](https://github.com/tomgrin10/paseo-session-guard) — Pause at session limits and continue after reset.
85
+ - [Transcript Search](https://github.com/tomgrin10/paseo-transcript-search) — Search conversations across connected hosts.
86
+ - [Transfer](https://github.com/tomgrin10/paseo-transfer) — Copy or move workspaces between hosts.
87
+ - [Vitals](https://github.com/tomgrin10/paseo-vitals) — Host, process, agent and Docker monitoring.
88
+ - [Workspace Switcher](https://github.com/tomgrin10/paseo-switcher) — Arc-style Ctrl+Tab between recent workspaces.
@@ -0,0 +1,43 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Lucide Icons and Contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16
+
17
+ ---
18
+
19
+ The following Lucide icons are derived from the Feather project:
20
+
21
+ airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
22
+
23
+ The MIT License (MIT) (for the icons listed above)
24
+
25
+ Copyright (c) 2013-present Cole Bemis
26
+
27
+ Permission is hereby granted, free of charge, to any person obtaining a copy
28
+ of this software and associated documentation files (the "Software"), to deal
29
+ in the Software without restriction, including without limitation the rights
30
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
31
+ copies of the Software, and to permit persons to whom the Software is
32
+ furnished to do so, subject to the following conditions:
33
+
34
+ The above copyright notice and this permission notice shall be included in all
35
+ copies or substantial portions of the Software.
36
+
37
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
38
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
39
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
40
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
41
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
42
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
43
+ SOFTWARE.
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><title>Smart Session</title><rect width="256" height="256" rx="52" fill="#1c2229"/><svg x="52" y="52" xmlns="http://www.w3.org/2000/svg" width="152" height="152" viewBox="0 0 24 24" fill="none" stroke="#f3f0e8" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gauge" aria-hidden="true"><path d="m12 14 4-4"></path><path d="M3.34 19a10 10 0 1 1 17.32 0"></path></svg></svg>
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The one agent directory this client bundle keeps, shared by every composer pill.
3
+ *
4
+ * Both the Smart Compact pill and the Session Guard pill need to know which agents
5
+ * exist and which workspace each sits in, since a pill registration bakes in its
6
+ * workspace. Following the host's agents twice would open two observations of the
7
+ * same directory; this opens one and lets each pill read it.
8
+ *
9
+ * It must be an *owned* observation, `agents.list({ subscribe: {}, signal })`: bare
10
+ * `agents.subscribe()` only listens to observations this API instance already holds,
11
+ * so on its own it never discovers a new agent (RESEARCH.md §5.1). A 0.8 runtime
12
+ * returns no observation and emits its updates through that local listener instead,
13
+ * so the listener stays attached until an owned observation replaces it.
14
+ */
15
+
16
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
17
+
18
+ export interface DirectoryAgent {
19
+ readonly workspaceId: string;
20
+ /** Closed or archived: still listed, but no longer a session anyone is working in. */
21
+ readonly closed: boolean;
22
+ }
23
+
24
+ export interface AgentDirectory {
25
+ current(): ReadonlyMap<string, DirectoryAgent>;
26
+ /** Called after every change; read `current()` for the new state. */
27
+ subscribe(listener: () => void): () => void;
28
+ /** Releases the observation. Safe to call more than once. */
29
+ dispose(): Promise<void>;
30
+ }
31
+
32
+ /** The lean shapes this needs from the agent directory, which carries far more. */
33
+ interface ListedAgent {
34
+ readonly id?: string;
35
+ readonly workspaceId?: string | null;
36
+ readonly status?: string;
37
+ readonly archivedAt?: string | null;
38
+ }
39
+
40
+ interface AgentUpdate {
41
+ readonly kind: "remove" | "upsert";
42
+ readonly agentId?: string;
43
+ readonly agent?: ListedAgent;
44
+ }
45
+
46
+ interface AgentListing {
47
+ readonly entries?: readonly { readonly agent?: ListedAgent }[];
48
+ readonly subscription?: {
49
+ subscribe(observer: {
50
+ snapshot(listing: AgentListing): void;
51
+ update(message: { readonly type?: string; readonly payload?: AgentUpdate }): void;
52
+ }): () => void;
53
+ release(): Promise<void>;
54
+ };
55
+ }
56
+
57
+ function entryOf(agent: ListedAgent | undefined): [string, DirectoryAgent] | null {
58
+ if (typeof agent?.id !== "string" || typeof agent.workspaceId !== "string" || agent.workspaceId === "") return null;
59
+ return [agent.id, { workspaceId: agent.workspaceId, closed: agent.status === "closed" || !!agent.archivedAt }];
60
+ }
61
+
62
+ function same(a: DirectoryAgent | undefined, b: DirectoryAgent): boolean {
63
+ return a !== undefined && a.workspaceId === b.workspaceId && a.closed === b.closed;
64
+ }
65
+
66
+ export function observeAgents(client: PluginClientContext): AgentDirectory {
67
+ const agents = new Map<string, DirectoryAgent>();
68
+ const listeners = new Set<() => void>();
69
+ const lifetime = new AbortController();
70
+ let stopped = false;
71
+ let unsubscribeOwnedObservation: (() => void) | null = null;
72
+ let releaseObservation: (() => Promise<void>) | null = null;
73
+
74
+ const notify = () => {
75
+ for (const listener of [...listeners]) listener();
76
+ };
77
+
78
+ function upsert(agent: ListedAgent | undefined): boolean {
79
+ const entry = entryOf(agent);
80
+ if (entry === null || same(agents.get(entry[0]), entry[1])) return false;
81
+ agents.set(entry[0], entry[1]);
82
+ return true;
83
+ }
84
+
85
+ function apply(update: AgentUpdate): void {
86
+ if (stopped) return;
87
+ if (update.kind === "remove") {
88
+ if (typeof update.agentId === "string" && agents.delete(update.agentId)) notify();
89
+ return;
90
+ }
91
+ if (upsert(update.agent)) notify();
92
+ }
93
+
94
+ /** The legacy listener is live while the snapshot is in flight, so bootstrap is additive. */
95
+ function add(entries: AgentListing["entries"]): void {
96
+ let changed = false;
97
+ for (const entry of entries ?? []) changed = upsert(entry.agent) || changed;
98
+ if (changed) notify();
99
+ }
100
+
101
+ /** A restored observation replaces its directory snapshot, including removals. */
102
+ function replace(entries: AgentListing["entries"]): void {
103
+ const present = new Set<string>();
104
+ let changed = false;
105
+ for (const entry of entries ?? []) {
106
+ const parsed = entryOf(entry.agent);
107
+ if (parsed === null) continue;
108
+ present.add(parsed[0]);
109
+ changed = upsert(entry.agent) || changed;
110
+ }
111
+ for (const agentId of [...agents.keys()]) {
112
+ if (!present.has(agentId)) {
113
+ agents.delete(agentId);
114
+ changed = true;
115
+ }
116
+ }
117
+ if (changed) notify();
118
+ }
119
+
120
+ let unsubscribeLegacy: (() => void) | null = client.paseo.agents.subscribe((update) => apply(update as AgentUpdate));
121
+
122
+ void (async () => {
123
+ try {
124
+ const listAgents = client.paseo.agents.list as unknown as (options: {
125
+ subscribe: Record<string, never>;
126
+ signal: AbortSignal;
127
+ }) => Promise<AgentListing>;
128
+ const listing = await listAgents({ subscribe: {}, signal: lifetime.signal });
129
+ const release = listing.subscription ? () => listing.subscription!.release() : null;
130
+ if (stopped) {
131
+ await release?.();
132
+ return;
133
+ }
134
+ releaseObservation = release;
135
+ add(listing.entries);
136
+ if (listing.subscription) {
137
+ unsubscribeOwnedObservation = listing.subscription.subscribe({
138
+ snapshot(snapshot) {
139
+ if (!stopped) replace(snapshot.entries);
140
+ },
141
+ update(message) {
142
+ if (message.type === "agent_update" && message.payload) apply(message.payload);
143
+ },
144
+ });
145
+ unsubscribeLegacy?.();
146
+ unsubscribeLegacy = null;
147
+ }
148
+ } catch {
149
+ const release = releaseObservation;
150
+ releaseObservation = null;
151
+ await release?.().catch(() => undefined);
152
+ // Not fatal to the rest of the client contribution; a reload can retry.
153
+ }
154
+ })();
155
+
156
+ return {
157
+ current: () => agents,
158
+ subscribe(listener) {
159
+ listeners.add(listener);
160
+ return () => {
161
+ listeners.delete(listener);
162
+ };
163
+ },
164
+ async dispose() {
165
+ stopped = true;
166
+ lifetime.abort();
167
+ unsubscribeLegacy?.();
168
+ unsubscribeLegacy = null;
169
+ unsubscribeOwnedObservation?.();
170
+ unsubscribeOwnedObservation = null;
171
+ listeners.clear();
172
+ agents.clear();
173
+ const release = releaseObservation;
174
+ releaseObservation = null;
175
+ await release?.().catch(() => undefined);
176
+ },
177
+ };
178
+ }
@@ -0,0 +1,32 @@
1
+ import type { PluginClientContext, PluginButtonRegistration } from "@getpaseo/plugin/client";
2
+ import { subscribeDisplay } from "./display";
3
+
4
+ export function withButtonVisibility(client: PluginClientContext) {
5
+ const entries = new Set<{ reconcile(): void; remove(): void }>();
6
+ let enableHeader = true;
7
+ let enableComposer = true;
8
+ function gated<K extends "addHeaderButton" | "addComposerPill">(method: K, input: Parameters<PluginClientContext[K]>[0]): PluginButtonRegistration {
9
+ let descriptor = input.button;
10
+ let registration: PluginButtonRegistration | undefined;
11
+ const entry = {
12
+ reconcile() {
13
+ const enabled = method === "addHeaderButton" ? enableHeader : enableComposer;
14
+ if (enabled && !registration) registration = (client[method] as (value: typeof input) => PluginButtonRegistration)({ ...input, button: descriptor });
15
+ if (!enabled && registration) { registration.remove(); registration = undefined; }
16
+ },
17
+ remove() { registration?.remove(); registration = undefined; entries.delete(entry); },
18
+ };
19
+ entries.add(entry);
20
+ entry.reconcile();
21
+ return { remove: entry.remove, update(patch) { descriptor = { ...descriptor, ...patch }; registration?.update(patch); } };
22
+ }
23
+ const buttons = Object.create(client) as PluginClientContext;
24
+ buttons.addHeaderButton = input => gated("addHeaderButton", input);
25
+ buttons.addComposerPill = input => gated("addComposerPill", input);
26
+ const unsubscribe = subscribeDisplay(values => {
27
+
28
+ enableComposer = values.enable_composer_button;
29
+ for (const entry of entries) entry.reconcile();
30
+ });
31
+ return { client: buttons, dispose() { unsubscribe(); for (const entry of [...entries]) entry.remove(); } };
32
+ }
@@ -0,0 +1,102 @@
1
+ import type { PluginClientContext, PluginSidebarContribution, PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { useSettings } from "@getpaseo/plugin/client";
3
+ import { SettingsAction, SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
4
+ import { useEffect } from "react";
5
+ import { ActivityIndicator, ScrollView, Text, View } from "react-native";
6
+ import { displayPreferences, displayRpc } from "../shared/display";
7
+ import { withNavigation } from "./navigation";
8
+ import { sidebarVisibility } from "./sidebar-visibility";
9
+
10
+ type Display = import("zod").infer<typeof displayPreferences.schema>;
11
+ const listeners = new Set<(values: Display) => void>();
12
+ export function subscribeDisplay(listener: (values: Display) => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }
13
+
14
+ function DisplaySettings({ theme }: PluginSurfaceProps) {
15
+ const settings = useSettings(displayPreferences);
16
+ const values = settings.status === "ready" ? settings.values : undefined;
17
+ useEffect(() => {
18
+ if (values !== undefined) for (const listener of listeners) listener(values);
19
+ }, [values]);
20
+
21
+ if (settings.status === "loading") return <ActivityIndicator accessibilityLabel="Loading display settings" color={theme.colors.foregroundMuted} />;
22
+ if (settings.status !== "ready") return (
23
+ <View style={{ gap: 12 }}>
24
+ <Text style={{ color: theme.colors.statusDanger }}>{settings.error}</Text>
25
+ <SettingsCard>
26
+ <SettingsAction label="Try loading the saved settings again" actionLabel="Retry" onPress={() => void settings.reload()} />
27
+ {settings.status === "invalid" && <SettingsAction label="Replace invalid display settings with defaults" actionLabel="Reset" disabled={settings.saving} onPress={() => void settings.reset()} />}
28
+
29
+ </SettingsCard>
30
+ {settings.saveError && <Text style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
31
+ </View>
32
+ );
33
+
34
+ return (
35
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
36
+ <SettingsSection title="Navigation">
37
+ <SettingsCard>
38
+ <SettingsSwitch
39
+ label="Show in sidebar"
40
+ hint="Open this plugin from Command-K (Ctrl-K on Windows and Linux) while viewing a workspace or plugin on this host."
41
+ value={settings.values.showSidebar}
42
+ disabled={settings.saving}
43
+ onValueChange={(showSidebar) => void settings.save({ ...settings.values, showSidebar }, settings.revision)}
44
+ />
45
+
46
+ <SettingsSwitch label="Show composer button" hint="Command-K and the sidebar remain available when this button is hidden." value={settings.values.enable_composer_button} disabled={settings.saving} onValueChange={(enable_composer_button) => void settings.save({ ...settings.values, enable_composer_button }, settings.revision)} />
47
+ </SettingsCard>
48
+ {settings.saveError && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
49
+ </SettingsSection>
50
+ </ScrollView>
51
+ );
52
+ }
53
+
54
+ /** Settings notifications reach mounted screens; poll for changes made on other clients. */
55
+ export function contributeDisplay(client: PluginClientContext, item: PluginSidebarContribution) {
56
+ const sidebar = sidebarVisibility(() => client.addSidebarItem(item));
57
+ // Paseo 0.10.1 memoizes sidebar route resolution when an installation mounts.
58
+ // Register synchronously, then reconcile the saved preference, so that an
59
+ // already-open sidebar route can resolve its surface during that first render.
60
+ sidebar.set(true);
61
+ const Settings = withNavigation(DisplaySettings, client, item.surface, "display");
62
+ const removeSurface = client.addSurface("display-settings", Settings);
63
+ const removeSettings = client.addSettingsScreen({ id: "display", title: "Display", icon: "PanelLeft", Component: Settings });
64
+ const removeCommand = client.addCommandCenterItem({
65
+ id: "display-settings",
66
+ title: `${item.title}: display settings`,
67
+ icon: "PanelLeft",
68
+ keywords: [item.title, "sidebar", "show", "hide", "display", "settings"],
69
+ context: "global",
70
+ onSelect({ openSurface }) { openSurface("display-settings"); },
71
+ });
72
+ let disposed = false;
73
+ let pending = false;
74
+ let generation = 0;
75
+ const update = (values: Display) => { generation += 1; sidebar.set(values.showSidebar); };
76
+ listeners.add(update);
77
+ const refresh = async () => {
78
+ if (disposed || pending) return;
79
+ pending = true;
80
+ const currentGeneration = generation;
81
+ try {
82
+ const result = await client.rpc(displayRpc.read, {});
83
+ if (!disposed && generation === currentGeneration && result.status === "ready") {
84
+ const parsed = displayPreferences.schema.safeParse(result.values);
85
+ if (parsed.success) { sidebar.set(parsed.data.showSidebar); for (const listener of listeners) listener(parsed.data); }
86
+ }
87
+ } catch {
88
+ // Retain the last valid preference through temporary disconnects.
89
+ } finally { pending = false; }
90
+ };
91
+ void refresh();
92
+ const timer = setInterval(() => void refresh(), 5000);
93
+ return () => {
94
+ disposed = true;
95
+ clearInterval(timer);
96
+ listeners.delete(update);
97
+ sidebar.dispose();
98
+ removeSurface();
99
+ removeSettings();
100
+ removeCommand();
101
+ };
102
+ }
@@ -0,0 +1,50 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { ScrollView } from "react-native";
3
+
4
+ import { TimelineDataSchema } from "../../shared/guard/timeline.ts";
5
+ import type { AgentDirectory } from "../agents.ts";
6
+ import { withNavigation } from "../navigation.tsx";
7
+ import { PausedSessions } from "./paused.tsx";
8
+ import { guardPillIcons } from "./pill-icons.tsx";
9
+ import { contributePills } from "./pills.ts";
10
+ import { GuardSettings } from "./settings.tsx";
11
+ import { GUARD_SURFACES } from "./surfaces.ts";
12
+ import { GuardTimelineItem } from "./timeline.tsx";
13
+
14
+ /** Session Guard's client half: its settings, paused-sessions list, timeline cards and pill. */
15
+ export function contributeGuardClient(client: PluginClientContext, agents: AgentDirectory, home: string) {
16
+ const openPaused = () => client.openSurface(GUARD_SURFACES.paused);
17
+ const openSettings = () => client.openSurface(GUARD_SURFACES.settings);
18
+ const Settings = withNavigation((props) => (
19
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}>
20
+ <GuardSettings {...props} openOverview={openPaused} />
21
+ </ScrollView>
22
+ ), client, home, "session-guard");
23
+ const Paused = withNavigation((props) => <PausedSessions {...props} openSettings={openSettings} />, client, home, "session-guard");
24
+
25
+ const removers = [
26
+ client.addSurface(GUARD_SURFACES.settings, Settings),
27
+ client.addSurface(GUARD_SURFACES.paused, Paused),
28
+ client.addSettingsScreen({ id: "session-guard", title: "Session Guard", icon: "ShieldCheck", Component: Settings }),
29
+ client.addCommandCenterItem({
30
+ id: "session-guard-settings", title: "Session Guard: settings", icon: "ShieldCheck",
31
+ keywords: ["quota", "limit", "reset", "pause", "resume", "protection", "session guard"], context: "global",
32
+ onSelect({ openSurface }) { openSurface(GUARD_SURFACES.settings); },
33
+ }),
34
+ client.addCommandCenterItem({
35
+ id: "session-guard-paused", title: "Session Guard: paused sessions", icon: "Pause",
36
+ keywords: ["quota", "waiting", "reset", "resume", "subagents", "session guard"], context: "global",
37
+ onSelect({ openSurface }) { openSurface(GUARD_SURFACES.paused); },
38
+ }),
39
+ // The kind and version the standalone plugin wrote, so a pause it recorded and this
40
+ // plugin later delivers renders the same card.
41
+ client.addTimelineRenderer({
42
+ kind: "quota-pause", version: 1, schema: TimelineDataSchema,
43
+ Component: (props) => <GuardTimelineItem {...props} openOverview={openPaused} />,
44
+ }),
45
+ contributePills(client, agents, guardPillIcons),
46
+ ];
47
+ return () => {
48
+ for (const remove of removers.splice(0)) remove();
49
+ };
50
+ }