paseo-smart-session 2.0.0 → 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.
- package/README.md +35 -139
- package/assets/LICENSE.lucide +43 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +1 -0
- package/client/agents.ts +178 -0
- package/client/guard/index.tsx +50 -0
- package/client/guard/paused.tsx +77 -0
- package/client/guard/pill-icons.tsx +26 -0
- package/client/guard/pills.ts +122 -0
- package/client/guard/settings.tsx +114 -0
- package/client/guard/surfaces.ts +2 -0
- package/client/guard/timeline.tsx +94 -0
- package/client/navigation.tsx +1 -1
- package/client/pill.tsx +12 -124
- package/client/surface.tsx +87 -2
- package/docs/session-guard.md +88 -0
- package/index.client.tsx +8 -3
- package/index.server.ts +5 -6
- package/mcp.mjs +21 -2
- package/package.json +2 -1
- package/paseo-plugin.json +1 -0
- package/server/guard/client.ts +27 -0
- package/server/guard/engine.ts +554 -0
- package/server/guard/index.ts +395 -0
- package/server/guard/labels.ts +77 -0
- package/server/guard/processes.ts +147 -0
- package/server/guard/standalone.ts +61 -0
- package/server/guard/store.ts +113 -0
- package/server/guard/timeline.ts +24 -0
- package/server/guard/usage.ts +57 -0
- package/server/recorder.ts +2 -0
- package/server/store.ts +25 -1
- package/shared/guard/contracts.ts +65 -0
- package/shared/guard/policy.ts +128 -0
- package/shared/guard/presentation.ts +85 -0
- package/shared/guard/timeline.ts +34 -0
- package/shared/lifecycle.ts +18 -0
package/README.md
CHANGED
|
@@ -5,188 +5,84 @@
|
|
|
5
5
|
[](https://paseo.sh)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
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
|

|
|
13
11
|
|
|
14
12
|
## Install
|
|
15
13
|
|
|
16
|
-
Install from npm using the latest Paseo release:
|
|
17
|
-
|
|
18
14
|
```sh
|
|
19
15
|
paseo plugin install paseo-smart-session
|
|
20
16
|
```
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
paseo plugin ls # confirm it is running
|
|
24
|
-
paseo plugin update smart-session
|
|
25
|
-
paseo plugin remove smart-session
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
On first load, Smart Session registers its Claude Code hooks. Its MCP server is attached by Paseo when an interactive session opens. It only manages entries that point at its own scripts; other Claude Code configuration is left alone.
|
|
29
|
-
|
|
30
|
-
## What it does
|
|
31
|
-
|
|
32
|
-
### Lets agents compact themselves
|
|
33
|
-
|
|
34
|
-
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.
|
|
35
|
-
|
|
36
|
-
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.
|
|
37
|
-
|
|
38
|
-
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.
|
|
39
|
-
|
|
40
|
-
### Shows session and plan usage
|
|
41
|
-
|
|
42
|
-
Smart Session keeps a local history of:
|
|
43
|
-
|
|
44
|
-
- Context-window usage for each agent session
|
|
45
|
-
- Claude plan usage, burn rate, and reset times
|
|
46
|
-
- Token spend by model, workspace, and main agent versus subagent
|
|
47
|
-
- Compaction requests and their before/after context sizes
|
|
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.
|
|
48
19
|
|
|
49
|
-
|
|
20
|
+
## Use
|
|
50
21
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
Smart Session exposes these MCP tools to Paseo agents:
|
|
22
|
+
Agents get these tools:
|
|
54
23
|
|
|
55
24
|
| Tool | What it does |
|
|
56
25
|
| --- | --- |
|
|
57
|
-
| `context_status` |
|
|
58
|
-
| `checkpoint` |
|
|
59
|
-
| `request_compaction` |
|
|
60
|
-
| `defer_compaction` |
|
|
61
|
-
| `budget_status` |
|
|
62
|
-
|
|
63
|
-
The usual flow is:
|
|
64
|
-
|
|
65
|
-
```text
|
|
66
|
-
context_status → checkpoint → request_compaction → stop, or re-read state and continue
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
`request_compaction` refuses when the state file is missing or stale. The agent can checkpoint and request again in the same turn.
|
|
70
|
-
|
|
71
|
-
## Smart Compact
|
|
72
|
-
|
|
73
|
-
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.
|
|
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 |
|
|
74
31
|
|
|
75
|
-
|
|
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.
|
|
76
33
|
|
|
77
|
-
|
|
34
|
+
The composer pill shows whether a session is enrolled in **Smart Compact**.
|
|
78
35
|
|
|
79
|
-
|
|
80
|
-
- `continue_after_compaction: true` to start another turn after compaction.
|
|
81
|
-
- `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**.
|
|
36
|
+
## How Smart Compact works
|
|
82
37
|
|
|
83
|
-
|
|
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.
|
|
84
39
|
|
|
85
|
-
|
|
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.
|
|
86
41
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
| State | Meaning |
|
|
90
|
-
| --- | --- |
|
|
91
|
-
| **Smart Compact on** | The session can receive context notices and request compaction. |
|
|
92
|
-
| **Smart Compact off** | The session is not enrolled. |
|
|
93
|
-
|
|
94
|
-
If Smart Compact is disabled globally, the pill is not shown.
|
|
95
|
-
|
|
96
|
-
## Settings
|
|
97
|
-
|
|
98
|
-
| Setting | Default | Description |
|
|
42
|
+
| Setting | Default | |
|
|
99
43
|
| --- | --- | --- |
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
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 |
|
|
106
50
|
|
|
107
|
-
|
|
51
|
+
## Session Guard
|
|
108
52
|
|
|
109
|
-
|
|
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**.
|
|
110
54
|
|
|
111
|
-
Smart Session
|
|
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.
|
|
112
56
|
|
|
113
|
-
|
|
114
|
-
$PASEO_HOME/plugin-data/smart-session/
|
|
115
|
-
```
|
|
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.
|
|
116
58
|
|
|
117
|
-
|
|
118
|
-
| --- | --- |
|
|
119
|
-
| `usage-YYYY-MM.jsonl` | Plan-usage observations and reset times. |
|
|
120
|
-
| `context-YYYY-MM.jsonl` | Context-window usage changes. |
|
|
121
|
-
| `compactions.json` | Compaction requests, continuation choices, and context sizes. |
|
|
122
|
-
| `spend-index.json` | Hourly token spend by model, workspace, and agent type. |
|
|
123
|
-
| `state/<agentId>.md` | Durable task state for one agent. |
|
|
124
|
-
| `settings.json`, `enrolment.json` | Plugin settings and per-session enrolment. |
|
|
125
|
-
|
|
126
|
-
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.
|
|
127
|
-
|
|
128
|
-
## Password-protected daemons
|
|
129
|
-
|
|
130
|
-
When the Paseo daemon requires a password, Smart Session resolves it in this order:
|
|
59
|
+
## Data and access
|
|
131
60
|
|
|
132
|
-
|
|
133
|
-
2. The file named by `PASEO_PASSWORD_FILE`
|
|
134
|
-
3. `~/paseo-hub/secrets/daemon-password`
|
|
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
|
-
|
|
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
65
|
## Development
|
|
139
66
|
|
|
140
67
|
```sh
|
|
141
|
-
git clone https://github.com/tomgrin10/paseo-smart-session.git
|
|
142
|
-
cd paseo-smart-session
|
|
143
68
|
npm ci
|
|
144
69
|
npm run verify
|
|
145
|
-
paseo plugin install
|
|
70
|
+
paseo plugin install .
|
|
71
|
+
paseo plugin reload smart-session # after changes
|
|
146
72
|
```
|
|
147
73
|
|
|
148
|
-
After editing:
|
|
149
|
-
|
|
150
|
-
```sh
|
|
151
|
-
npm run verify
|
|
152
|
-
paseo plugin reload smart-session
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
Reloading the plugin is enough. Do not restart the Paseo daemon: that interrupts every running agent.
|
|
156
|
-
|
|
157
|
-
## Data and privacy
|
|
158
|
-
|
|
159
|
-
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 entries, it does not modify user configuration. MCP attachment is local to each Paseo session.
|
|
160
|
-
|
|
161
74
|
## License
|
|
162
75
|
|
|
163
76
|
[MIT](LICENSE) © 2026 Tom Gringauz.
|
|
164
77
|
|
|
165
|
-
## Navigation and display
|
|
166
|
-
|
|
167
|
-
Open this plugin from Command-K or its sidebar entry. **Overview** and **Display settings** link the main view and settings directly, without opening the Plugins settings page. Hiding a sidebar entry keeps Command-K available.
|
|
168
|
-
|
|
169
|
-
Display settings can also hide this plugin's workspace header or composer buttons independently. Hidden buttons stay hidden for newly opened workspaces; restoring them retains the latest button status.
|
|
170
|
-
|
|
171
|
-
## Paseo 0.14 agent integration
|
|
172
|
-
|
|
173
|
-
The Smart Session MCP attaches automatically when an interactive Claude or Codex session opens, including resume, refresh and import. Normal plugin startup no longer registers a global Claude MCP. Existing global entries are preserved; usage history and checkpoints remain in place. The bridge also respects a custom daemon listen address.
|
|
174
|
-
|
|
175
|
-
Requires Paseo **0.14.0 or newer**. Agent tools are scoped to interactive Claude
|
|
176
|
-
and Codex sessions; history-only opens receive none. They become available on
|
|
177
|
-
the next idle session open after install or reload. The agent label
|
|
178
|
-
`plugin-tools/enabled: "false"` opts a session out. Existing plugin data and
|
|
179
|
-
settings are retained.
|
|
180
|
-
|
|
181
78
|
## Other plugins
|
|
182
79
|
|
|
183
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.
|
|
184
82
|
- [Graphite](https://github.com/tomgrin10/paseo-graphite) — Graphite stacks and PR status.
|
|
185
|
-
- [
|
|
83
|
+
- [Send to Paseo](https://github.com/tomgrin10/send-to-paseo) — Send GitHub and Graphite PRs to Paseo from Chrome.
|
|
186
84
|
- [Session Guard](https://github.com/tomgrin10/paseo-session-guard) — Pause at session limits and continue after reset.
|
|
187
|
-
- [Transfer](https://github.com/tomgrin10/paseo-transfer) — Copy or move workspaces between hosts.
|
|
188
85
|
- [Transcript Search](https://github.com/tomgrin10/paseo-transcript-search) — Search conversations across connected hosts.
|
|
189
|
-
- [
|
|
190
|
-
- [
|
|
191
|
-
|
|
192
|
-
- [Follow-up](https://github.com/tomgrin10/paseo-follow-up) — Track workspace PRs, remaining tasks and completion.
|
|
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.
|
package/assets/icon.png
ADDED
|
Binary file
|
package/assets/icon.svg
ADDED
|
@@ -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>
|
package/client/agents.ts
ADDED
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { ActivityIndicator, Text, View } from "react-native";
|
|
3
|
+
import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
|
|
4
|
+
import { ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
5
|
+
import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection } from "@getpaseo/plugin/client/ui";
|
|
6
|
+
import { cancelResumeRpc, statusRpc } from "../../shared/guard/contracts.ts";
|
|
7
|
+
import { governorNotice, pauseOwnerNote, pausedSessions, pausePresentation } from "../../shared/guard/presentation.ts";
|
|
8
|
+
|
|
9
|
+
export function PausedSessions({ theme, host, layout, navigation, openSettings }: PluginSurfaceProps & { openSettings(): void }) {
|
|
10
|
+
const read = useRpc(statusRpc);
|
|
11
|
+
const cancel = useRpc(cancelResumeRpc);
|
|
12
|
+
const [status, setStatus] = useState<Awaited<ReturnType<typeof read>> | null>(null);
|
|
13
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
14
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
15
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
16
|
+
const [query, setQuery] = useState("");
|
|
17
|
+
const refresh = useRef<() => Promise<void>>(async () => {});
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let stopped = false, fetching = false;
|
|
20
|
+
const fetch = async () => {
|
|
21
|
+
if (fetching || stopped) return;
|
|
22
|
+
fetching = true;
|
|
23
|
+
try { const next = await read({}); if (!stopped) { setStatus(next); setLoadError(null); } }
|
|
24
|
+
catch { if (!stopped) setLoadError("Could not refresh paused sessions. Showing the last report; retrying automatically."); }
|
|
25
|
+
finally { fetching = false; }
|
|
26
|
+
};
|
|
27
|
+
refresh.current = fetch;
|
|
28
|
+
void fetch(); const timer = setInterval(() => void fetch(), 5000);
|
|
29
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
30
|
+
}, [read]);
|
|
31
|
+
const release = async (agentId: string) => {
|
|
32
|
+
if (busy) return;
|
|
33
|
+
setBusy(agentId);
|
|
34
|
+
try { await cancel({ agentId }); await refresh.current(); setActionError(null); }
|
|
35
|
+
catch { setActionError("Could not change this pause. Refresh the list and try again."); }
|
|
36
|
+
finally { setBusy(null); }
|
|
37
|
+
};
|
|
38
|
+
const all = status ? pausedSessions(status.pauses) : [];
|
|
39
|
+
const visible = status ? pausedSessions(status.pauses, query) : [];
|
|
40
|
+
const attention = visible.filter(p => p.phase === "attention");
|
|
41
|
+
const waiting = visible.filter(p => p.phase !== "attention");
|
|
42
|
+
const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 20 };
|
|
43
|
+
// Both plugins share one state file: when this one is not governing, the list is
|
|
44
|
+
// the other plugin's (or a leftover), and its controls would only fail.
|
|
45
|
+
const notice = status ? governorNotice(status) : null;
|
|
46
|
+
const owner = status ? pauseOwnerNote(status) : null;
|
|
47
|
+
const row = (pause: typeof all[number]) => {
|
|
48
|
+
const presentation = pausePresentation(pause, status!.policy);
|
|
49
|
+
const hint = `${pause.provider} · ${pause.agentId.slice(0, 8)} · ${presentation.coverage}\n${presentation.resetLabel}\n${owner ?? presentation.state}${pause.note ? `\n${pause.note}` : ""}`;
|
|
50
|
+
return <View key={pause.id}>
|
|
51
|
+
{navigation ? <SettingsAction label={presentation.title} hint={hint} actionLabel="Open session" onPress={() => navigation.openAgent({ agentId: pause.agentId, serverId: host.id })} />
|
|
52
|
+
: <SettingsRow label={presentation.title} hint={hint} />}
|
|
53
|
+
{owner === null && <SettingsAction label={busy === pause.agentId ? "Updating this pause…" : "Manual control"} hint={presentation.actionHint} actionLabel={presentation.actionLabel} disabled={busy !== null} onPress={() => void release(pause.agentId)} />}
|
|
54
|
+
</View>;
|
|
55
|
+
};
|
|
56
|
+
return <ScrollView contentContainerStyle={{ padding: layout.compact ? 16 : 24, paddingBottom: 40 }}>
|
|
57
|
+
<View style={{ width: "100%", maxWidth: 760, alignSelf: "center", gap: 24 }}>
|
|
58
|
+
<View style={{ gap: 8 }}>
|
|
59
|
+
<Text accessibilityRole="header" style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "600" }}>Paused sessions</Text>
|
|
60
|
+
<Text style={muted}>All Session Guard pauses on {host.label}. Native subagents are included with their parent session.</Text>
|
|
61
|
+
</View>
|
|
62
|
+
{!status && !loadError && <ActivityIndicator accessibilityLabel="Loading paused sessions" color={theme.colors.foregroundMuted} />}
|
|
63
|
+
{notice && <SettingsCard><SettingsAction label={notice.title} hint={notice.hint} actionLabel="Settings" onPress={openSettings} /></SettingsCard>}
|
|
64
|
+
{status && !notice && <SettingsCard><SettingsAction label={`${all.length} ${all.length === 1 ? "session" : "sessions"} in Session Guard${all.some(p => p.phase === "attention") ? " · attention needed" : ""}`} hint={status.checkedAt ? `Last checked ${new Date(status.checkedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} · refreshes automatically` : "Waiting for the first usage check"} actionLabel="Refresh" onPress={() => void refresh.current()} /></SettingsCard>}
|
|
65
|
+
{(all.length > 5 || query !== "") && <SettingsCard><SettingsInput label="Find a paused session" placeholder="Title or provider" initialValue="" onChangeText={setQuery} /></SettingsCard>}
|
|
66
|
+
{status && all.length === 0 && !notice && <SettingsCard><SettingsRow label="No sessions are paused" hint="When a protected session reaches its limit, it appears here and receives a timeline entry." /></SettingsCard>}
|
|
67
|
+
{status && all.length > 0 && visible.length === 0 && <SettingsCard><SettingsRow label="No matching paused sessions" hint="Try another title or provider." /></SettingsCard>}
|
|
68
|
+
{owner && visible.length > 0 && <SettingsSection title={`Not managed by Smart Session (${visible.length})`}><SettingsCard>{visible.map(row)}</SettingsCard></SettingsSection>}
|
|
69
|
+
{!owner && attention.length > 0 && <SettingsSection title={`Needs attention (${attention.length})`}><SettingsCard>{attention.map(row)}</SettingsCard></SettingsSection>}
|
|
70
|
+
{!owner && waiting.length > 0 && <SettingsSection title={`Waiting for reset (${waiting.length})`}><SettingsCard>{waiting.map(row)}</SettingsCard></SettingsSection>}
|
|
71
|
+
<SettingsCard><SettingsAction label="Protection and timing" hint="Configure the default, native subagents, and automatic resume." actionLabel="Settings" onPress={openSettings} /></SettingsCard>
|
|
72
|
+
{loadError && <Text accessibilityRole="alert" style={{ ...muted, color: theme.colors.statusDanger }}>{status ? loadError : "Could not load paused sessions. Retrying automatically."}</Text>}
|
|
73
|
+
{actionError && <Text accessibilityRole="alert" style={{ ...muted, color: theme.colors.statusDanger }}>{actionError}</Text>}
|
|
74
|
+
{status?.error && !notice && <Text accessibilityRole="alert" style={{ ...muted, color: theme.colors.statusDanger }}>{status.error}</Text>}
|
|
75
|
+
</View>
|
|
76
|
+
</ScrollView>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PluginButtonIcon, PluginButtonIconProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
|
|
4
|
+
// Like Smart Compact, keep the host's pill chrome and color the icon slot.
|
|
5
|
+
function GuardOnIcon({ theme, size }: PluginButtonIconProps) {
|
|
6
|
+
return <Icon name="ShieldCheck" size={size} color={theme.colors.statusSuccess} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function GuardOffIcon({ theme, size }: PluginButtonIconProps) {
|
|
10
|
+
return <Icon name="ShieldOff" size={size} color={theme.colors.foregroundMuted} />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function GuardPausedIcon({ theme, size }: PluginButtonIconProps) {
|
|
14
|
+
return <Icon name="Pause" size={size} color={theme.colors.statusWarning} />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function GuardAttentionIcon({ theme, size }: PluginButtonIconProps) {
|
|
18
|
+
return <Icon name="TriangleAlert" size={size} color={theme.colors.statusDanger} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const guardPillIcons: Readonly<Record<string, PluginButtonIcon>> = {
|
|
22
|
+
ShieldCheck: GuardOnIcon,
|
|
23
|
+
ShieldOff: GuardOffIcon,
|
|
24
|
+
Pause: GuardPausedIcon,
|
|
25
|
+
TriangleAlert: GuardAttentionIcon,
|
|
26
|
+
};
|