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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, open, unlink, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { StateSchema, type State } from "../../shared/guard/policy.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where Session Guard keeps its pauses, session overrides and governor lock.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately the standalone `paseo-session-guard` plugin's directory, not
|
|
11
|
+
* `plugin-data/smart-session/`. Both plugins may be installed on one host while people
|
|
12
|
+
* move over, and both would otherwise pause the same agent. Sharing this directory
|
|
13
|
+
* means they share `governor.lock`, so exactly one of them governs; and when the
|
|
14
|
+
* standalone plugin is removed, its per-session choices and confirmed waits are picked
|
|
15
|
+
* up here instead of being lost. The state shape is therefore frozen: the standalone
|
|
16
|
+
* plugin reads and rewrites the same file, and its schema would strip anything new.
|
|
17
|
+
*/
|
|
18
|
+
export function guardDataDir(): string {
|
|
19
|
+
return join(process.env.PASEO_HOME ?? join(homedir(), ".paseo"), "plugin-data", "session-guard");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A live process already governs this host. Not a failure: the caller defers to it. */
|
|
23
|
+
export class LockHeldError extends Error {}
|
|
24
|
+
|
|
25
|
+
export class Store {
|
|
26
|
+
readonly directory: string;
|
|
27
|
+
constructor(directory: string) { this.directory = directory; }
|
|
28
|
+
async read(): Promise<State> {
|
|
29
|
+
try {
|
|
30
|
+
return StateSchema.parse(JSON.parse(await readFile(join(this.directory, "state.json"), "utf8")));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, overrides: {}, pauses: [] };
|
|
33
|
+
throw new Error("Session Guard state could not be read. Restore state.json before continuing.");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async write(state: State): Promise<void> {
|
|
37
|
+
const parsed = StateSchema.parse(state);
|
|
38
|
+
// Keep pending work and a bounded history; never write transcripts or prompts.
|
|
39
|
+
const pending = parsed.pauses.filter(p => p.settledAt === null);
|
|
40
|
+
const history = parsed.pauses.filter(p => p.settledAt !== null).slice(-200);
|
|
41
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
42
|
+
const temporary = join(this.directory, `state-${randomUUID()}.tmp`);
|
|
43
|
+
try {
|
|
44
|
+
const file = await open(temporary, "wx", 0o600);
|
|
45
|
+
try {
|
|
46
|
+
await file.writeFile(JSON.stringify({ ...parsed, pauses: [...history, ...pending] }, null, 2));
|
|
47
|
+
await file.sync(); // Confirmed schedules must reach disk before signals.
|
|
48
|
+
} finally { await file.close(); }
|
|
49
|
+
await rename(temporary, join(this.directory, "state.json"));
|
|
50
|
+
if (process.platform === "linux") {
|
|
51
|
+
const directory = await open(this.directory, "r");
|
|
52
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
53
|
+
}
|
|
54
|
+
} finally { await unlink(temporary).catch(() => undefined); }
|
|
55
|
+
}
|
|
56
|
+
/** One governor per host, including installs under a second plugin ID. */
|
|
57
|
+
async lock(): Promise<() => Promise<void>> {
|
|
58
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
59
|
+
const path = join(this.directory, "governor.lock");
|
|
60
|
+
const identity = async (pid: number) => {
|
|
61
|
+
if (process.platform !== "linux") return null;
|
|
62
|
+
const boot = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
|
|
63
|
+
const raw = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
64
|
+
const birth = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/)[19];
|
|
65
|
+
return `${boot}:${birth}`;
|
|
66
|
+
};
|
|
67
|
+
const token = `${process.pid}:${randomUUID()}:${await identity(process.pid) ?? "portable"}`;
|
|
68
|
+
const alive = async (value: string) => {
|
|
69
|
+
const [pidText, , ...saved] = value.split(":"); const pid = Number(pidText);
|
|
70
|
+
if (!Number.isInteger(pid) || pid <= 0) throw new Error("Governor lock has an invalid owner.");
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, 0);
|
|
73
|
+
return saved.length < 2 || process.platform !== "linux" || await identity(pid) === saved.join(":");
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (["ESRCH", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) return false;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const claim = async () => {
|
|
80
|
+
const handle = await open(path, "wx", 0o600);
|
|
81
|
+
try { await handle.writeFile(token); } finally { await handle.close(); }
|
|
82
|
+
};
|
|
83
|
+
try { await claim(); } catch (error) {
|
|
84
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
85
|
+
const previous = await readFile(path, "utf8");
|
|
86
|
+
if (await alive(previous)) throw new LockHeldError("Another Session Guard instance is already running.");
|
|
87
|
+
// Stale-lock recovery must itself be exclusive across simultaneous loads.
|
|
88
|
+
const recoveryPath = `${path}.recovery`;
|
|
89
|
+
const abandoned = await readFile(recoveryPath, "utf8").catch(error => {
|
|
90
|
+
if (error.code === "ENOENT") return null;
|
|
91
|
+
throw error;
|
|
92
|
+
});
|
|
93
|
+
if (abandoned !== null) {
|
|
94
|
+
// Older releases left an empty recovery file. Allow its writer time to
|
|
95
|
+
// finish; a nonempty owner is checked by boot and process birth identity.
|
|
96
|
+
const expired = abandoned === "" ? Date.now() - (await stat(recoveryPath)).mtimeMs > 30_000 : !await alive(abandoned);
|
|
97
|
+
if (!expired) throw new LockHeldError("Governor recovery is already running.");
|
|
98
|
+
if (await readFile(recoveryPath, "utf8") !== abandoned) throw new Error("Governor recovery changed.");
|
|
99
|
+
await unlink(recoveryPath);
|
|
100
|
+
}
|
|
101
|
+
const recovery = await open(recoveryPath, "wx", 0o600);
|
|
102
|
+
await recovery.writeFile(token);
|
|
103
|
+
try {
|
|
104
|
+
if (await readFile(path, "utf8") !== previous) throw new Error("Governor lock changed; reload Session Guard.");
|
|
105
|
+
await unlink(path);
|
|
106
|
+
await claim();
|
|
107
|
+
} finally { await recovery.close(); await unlink(`${path}.recovery`).catch(() => undefined); }
|
|
108
|
+
}
|
|
109
|
+
return async () => {
|
|
110
|
+
if (await readFile(path, "utf8").catch(() => null) === token) await unlink(path);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import type { Notification } from "../../shared/guard/timeline.ts";
|
|
3
|
+
|
|
4
|
+
export type PublishTimeline = (agentId: string, notification: Notification, reconcile: boolean) => Promise<void>;
|
|
5
|
+
export function timelinePublisher(api: () => Promise<PluginHandlerContext["paseo"]>): PublishTimeline {
|
|
6
|
+
return async (agentId, notification, reconcile) => {
|
|
7
|
+
const timeline = (await api()).agents.ref(agentId).timeline;
|
|
8
|
+
if (reconcile) {
|
|
9
|
+
// Lost acknowledgements are checked against durable history before retry.
|
|
10
|
+
// Only IDs/timestamps are inspected; content is never logged or retained.
|
|
11
|
+
let cursor: NonNullable<Parameters<typeof timeline.refetch>[0]>["cursor"];
|
|
12
|
+
for (let page = 0; page < 32; page++) {
|
|
13
|
+
const result = await timeline.refetch({ direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 100, projection: "canonical" });
|
|
14
|
+
if (result.error || result.staleCursor) throw new Error("Timeline receipt could not be checked.");
|
|
15
|
+
if (result.entries.some(entry => entry.item.type === "plugin" && entry.item.id === notification.id)) return;
|
|
16
|
+
if (!result.hasOlder || result.entries.some(entry => Date.parse(entry.timestamp) < Date.parse(notification.data.at))) break;
|
|
17
|
+
if (!result.startCursor) throw new Error("Timeline receipt cursor is unavailable.");
|
|
18
|
+
cursor = result.startCursor;
|
|
19
|
+
if (page === 31) throw new Error("Timeline receipt check exceeded its page budget.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
await timeline.append({ type: "plugin", id: notification.id, kind: "quota-pause", version: 1, data: notification.data });
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Guard's usage reading, sharpened with Smart Session's recorder.
|
|
3
|
+
*
|
|
4
|
+
* The guard needs every provider's session window, which only Paseo's
|
|
5
|
+
* `provider.usage.list` reports. For Claude, though, Paseo serves a cache up to five
|
|
6
|
+
* minutes old and cannot be made to refresh (RESEARCH.md §7a), while the recorder in
|
|
7
|
+
* this same process asks Anthropic directly every three minutes. Whichever reading is
|
|
8
|
+
* newer wins, so a pause and — more usefully — its release follow the fresher number
|
|
9
|
+
* instead of waiting out Paseo's cache.
|
|
10
|
+
*
|
|
11
|
+
* Only a reading that says when the provider produced it is used: `resetReady` trusts
|
|
12
|
+
* `fetchedAt` to prove a report is newer than the pause, and an observation time is not
|
|
13
|
+
* that.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { canonicalWindowId, effectiveAt, type UsageSample } from "../../shared/usage.ts";
|
|
17
|
+
import type { ProviderUsage, Usage, UsageWindow } from "../../shared/guard/policy.ts";
|
|
18
|
+
|
|
19
|
+
/** The provider the recorder measures. It records nothing for any other. */
|
|
20
|
+
const RECORDED_PROVIDER = "claude";
|
|
21
|
+
|
|
22
|
+
export function withRecordedUsage(paseo: Usage, recorded: UsageSample | null): Usage {
|
|
23
|
+
if (recorded === null || recorded.fetchedAt === null) return paseo;
|
|
24
|
+
const recordedMs = Date.parse(effectiveAt(recorded));
|
|
25
|
+
if (!Number.isFinite(recordedMs)) return paseo;
|
|
26
|
+
|
|
27
|
+
const index = paseo.providers.findIndex((provider) => provider.providerId === RECORDED_PROVIDER);
|
|
28
|
+
const current = index === -1 ? undefined : paseo.providers[index];
|
|
29
|
+
if (current?.status === "available") {
|
|
30
|
+
const paseoMs = Date.parse(current.fetchedAt ?? paseo.fetchedAt);
|
|
31
|
+
if (Number.isFinite(paseoMs) && paseoMs >= recordedMs) return paseo;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Keep Paseo's window ids, which are what the policy's `windowIds` name, and fill
|
|
35
|
+
// them from the recorder; the recorder's own canonical ids cover any it lacks.
|
|
36
|
+
const used = new Set<string>();
|
|
37
|
+
const windows: UsageWindow[] = [];
|
|
38
|
+
for (const window of current?.status === "available" ? current.windows : []) {
|
|
39
|
+
const id = canonicalWindowId(window.id);
|
|
40
|
+
const reading = recorded.windows[id];
|
|
41
|
+
if (reading === undefined) {
|
|
42
|
+
windows.push(window);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
used.add(id);
|
|
46
|
+
windows.push({ id: window.id, usedPct: reading.pct, remainingPct: null, resetsAt: reading.resetsAt ?? window.resetsAt ?? null });
|
|
47
|
+
}
|
|
48
|
+
for (const [id, reading] of Object.entries(recorded.windows)) {
|
|
49
|
+
if (!used.has(id)) windows.push({ id, usedPct: reading.pct, remainingPct: null, resetsAt: reading.resetsAt });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const merged: ProviderUsage = { providerId: RECORDED_PROVIDER, status: "available", fetchedAt: new Date(recordedMs).toISOString(), windows };
|
|
53
|
+
const providers = [...paseo.providers];
|
|
54
|
+
if (index === -1) providers.push(merged);
|
|
55
|
+
else providers[index] = merged;
|
|
56
|
+
return { ...paseo, providers };
|
|
57
|
+
}
|
package/server/recorder.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
appendUsage,
|
|
23
23
|
clearCaches,
|
|
24
24
|
newestUsage,
|
|
25
|
+
noteAcceptedReading,
|
|
25
26
|
noteNewestUsage,
|
|
26
27
|
type ContextSample,
|
|
27
28
|
} from "./store.ts";
|
|
@@ -86,6 +87,7 @@ async function record(sample: UsageSample): Promise<boolean> {
|
|
|
86
87
|
const newest = await newestUsage();
|
|
87
88
|
const verdict = acceptReading(newest, sample);
|
|
88
89
|
if (!verdict.ok) return false;
|
|
90
|
+
noteAcceptedReading(sample);
|
|
89
91
|
if (!isWorthRecording(newest, sample)) return false;
|
|
90
92
|
await appendUsage(sample);
|
|
91
93
|
noteNewestUsage(sample);
|
package/server/store.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
|
-
import type
|
|
15
|
+
import { effectiveAt, type UsageSample } from "../shared/usage.ts";
|
|
16
16
|
|
|
17
17
|
const DATA_DIRECTORY = "smart-session";
|
|
18
18
|
const LEGACY_DATA_DIRECTORY = "super-session";
|
|
@@ -195,7 +195,31 @@ export function noteNewestUsage(sample: UsageSample): void {
|
|
|
195
195
|
newestCache = { value: sample };
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The newest reading that passed the acceptance gate, whether or not it was written.
|
|
200
|
+
*
|
|
201
|
+
* Most readings are refused by `isWorthRecording` only because nothing moved, so the
|
|
202
|
+
* newest *line* can be minutes older than the newest *fact*. Session Guard wants the
|
|
203
|
+
* fact — a fresh report below its threshold is what confirms a reset.
|
|
204
|
+
*/
|
|
205
|
+
let latestAccepted: UsageSample | null = null;
|
|
206
|
+
|
|
207
|
+
export function noteAcceptedReading(sample: UsageSample): void {
|
|
208
|
+
if (latestAccepted === null || Date.parse(effectiveAt(sample)) >= Date.parse(effectiveAt(latestAccepted))) {
|
|
209
|
+
latestAccepted = sample;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The freshest Claude plan-usage reading this process knows of, recorded or not. */
|
|
214
|
+
export async function freshestUsage(): Promise<UsageSample | null> {
|
|
215
|
+
const newest = await newestUsage();
|
|
216
|
+
if (latestAccepted === null) return newest;
|
|
217
|
+
if (newest === null) return latestAccepted;
|
|
218
|
+
return Date.parse(effectiveAt(latestAccepted)) >= Date.parse(effectiveAt(newest)) ? latestAccepted : newest;
|
|
219
|
+
}
|
|
220
|
+
|
|
198
221
|
/** Drops in-memory state so a reload starts clean. */
|
|
199
222
|
export function clearCaches(): void {
|
|
200
223
|
newestCache = null;
|
|
224
|
+
latestAccepted = null;
|
|
201
225
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defineRpc, defineSettings } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { PauseSchema, PolicySchema } from "./policy.ts";
|
|
4
|
+
|
|
5
|
+
/** Host-scoped, so every client of this daemon sees one Session Guard policy. */
|
|
6
|
+
export const guardPreferences = defineSettings({ id: "session-guard", scope: "host", version: 1, schema: PolicySchema });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Who is running Session Guard on this host.
|
|
10
|
+
*
|
|
11
|
+
* - `off`: the feature is disabled here, and this plugin holds no lock.
|
|
12
|
+
* - `active`: this plugin is the host's one governor.
|
|
13
|
+
* - `deferred`: the standalone `paseo-session-guard` plugin is enabled, or another live
|
|
14
|
+
* process holds the governor lock. Nothing is paused from here until that changes.
|
|
15
|
+
* - `error`: the saved state or the lock could not be read.
|
|
16
|
+
*/
|
|
17
|
+
export const GovernorSchema = z.enum(["off", "active", "deferred", "error"]);
|
|
18
|
+
export type Governor = z.infer<typeof GovernorSchema>;
|
|
19
|
+
|
|
20
|
+
const ProviderSchema = z.object({ providerId: z.string(), usedPct: z.number().nullable(), resetsAt: z.string().nullable() });
|
|
21
|
+
|
|
22
|
+
export const statusRpc = defineRpc({
|
|
23
|
+
name: "smart-session.guard.status", input: z.object({}),
|
|
24
|
+
output: z.object({
|
|
25
|
+
governor: GovernorSchema,
|
|
26
|
+
/** Whether the standalone plugin is configured and enabled; null when unknown or not checked. */
|
|
27
|
+
standalone: z.boolean().nullable(),
|
|
28
|
+
checkedAt: z.string().nullable(), error: z.string().nullable(),
|
|
29
|
+
pauses: z.array(PauseSchema), overrides: z.record(z.string(), z.boolean()),
|
|
30
|
+
policy: PolicySchema,
|
|
31
|
+
providers: z.array(ProviderSchema),
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
/** One agent's view, for the agent itself: its own protection and pauses, nobody else's. */
|
|
35
|
+
export const agentStatusRpc = defineRpc({
|
|
36
|
+
name: "smart-session.guard.agent", input: z.object({ agentId: z.string().min(1) }),
|
|
37
|
+
output: z.object({
|
|
38
|
+
governor: GovernorSchema,
|
|
39
|
+
enabled: z.boolean(), protected: z.boolean(), override: z.boolean().nullable(),
|
|
40
|
+
thresholdPct: z.number(), autoResume: z.boolean(),
|
|
41
|
+
pauses: z.array(z.object({ id: z.string(), phase: PauseSchema.shape.phase, provider: z.string(), resetsAt: z.string().nullable() })),
|
|
42
|
+
providers: z.array(ProviderSchema),
|
|
43
|
+
summary: z.array(z.string()),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
export const setProtectionRpc = defineRpc({
|
|
47
|
+
name: "smart-session.guard.protection", input: z.object({ agentId: z.string().min(1), enabled: z.boolean().nullable() }),
|
|
48
|
+
output: z.object({ ok: z.boolean() }),
|
|
49
|
+
});
|
|
50
|
+
export const cancelResumeRpc = defineRpc({
|
|
51
|
+
name: "smart-session.guard.cancel", input: z.object({ agentId: z.string().min(1) }), output: z.object({ ok: z.boolean() }),
|
|
52
|
+
});
|
|
53
|
+
export const resumeNowRpc = defineRpc({
|
|
54
|
+
name: "smart-session.guard.resume-now", input: z.object({ agentId: z.string().min(1), pauseId: z.string().min(1) }),
|
|
55
|
+
output: z.object({ ok: z.boolean() }),
|
|
56
|
+
});
|
|
57
|
+
export const dismissPauseRpc = defineRpc({
|
|
58
|
+
name: "smart-session.guard.dismiss-pause", input: z.object({ agentId: z.string().min(1), pauseId: z.string().min(1) }),
|
|
59
|
+
output: z.object({ ok: z.boolean() }),
|
|
60
|
+
});
|
|
61
|
+
export const demoPauseRpc = defineRpc({
|
|
62
|
+
name: "smart-session.guard.demo-pause", input: z.object({ agentId: z.string().min(1) }), output: z.object({ ok: z.boolean() }),
|
|
63
|
+
});
|
|
64
|
+
// Obtains the host-owned IPC API; network clients cannot impersonate plugins.
|
|
65
|
+
export const runtimeRpc = defineRpc({ name: "smart-session.guard.runtime", input: z.object({}), output: z.object({ ok: z.boolean() }) });
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Guard: pausing work at a provider's session limit and continuing it after
|
|
3
|
+
* the window resets.
|
|
4
|
+
*
|
|
5
|
+
* This module, and the rest of `shared/guard/`, `server/guard/` and `client/guard/`,
|
|
6
|
+
* was folded in from the standalone `paseo-session-guard` plugin. The pause record and
|
|
7
|
+
* state file are deliberately unchanged (see `server/guard/store.ts`): during the
|
|
8
|
+
* transition both plugins can be installed on one host, and sharing one state file and
|
|
9
|
+
* one governor lock is what guarantees a session is never paused twice.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { NotificationSchema } from "./timeline.ts";
|
|
13
|
+
|
|
14
|
+
export const PolicySchema = z.object({
|
|
15
|
+
/**
|
|
16
|
+
* Off by default, unlike the standalone plugin. Pausing a running agent is a much
|
|
17
|
+
* bigger thing to do unasked than drawing a chart, and an existing Smart Session
|
|
18
|
+
* install must not start freezing processes on upgrade.
|
|
19
|
+
*/
|
|
20
|
+
enabled: z.boolean().default(false),
|
|
21
|
+
protectByDefault: z.boolean().default(true),
|
|
22
|
+
thresholdPct: z.number().min(1).max(100).default(100),
|
|
23
|
+
pollSeconds: z.number().int().min(5).max(300).default(15),
|
|
24
|
+
resetGraceSeconds: z.number().int().min(0).max(600).default(30),
|
|
25
|
+
resumeSpacingSeconds: z.number().int().min(0).max(300).default(5),
|
|
26
|
+
autoResume: z.boolean().default(true),
|
|
27
|
+
includeNativeSubagents: z.boolean().default(true),
|
|
28
|
+
workspaceLabels: z.boolean().default(true),
|
|
29
|
+
providerIds: z.array(z.string().min(1)).default([]),
|
|
30
|
+
windowIds: z.array(z.string().min(1)).min(1).default(["five_hour", "session"]),
|
|
31
|
+
continuation: z.string().trim().min(1).max(8000).default(
|
|
32
|
+
"Your previous turn was paused because the provider's session usage limit was reached. " +
|
|
33
|
+
"The usage window has now reset. Continue the unfinished task from where you left off, " +
|
|
34
|
+
"using this conversation and the current workspace state. Check any interrupted operation " +
|
|
35
|
+
"before retrying it. If the task is already complete, do not repeat it.",
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
export type Policy = z.infer<typeof PolicySchema>;
|
|
39
|
+
|
|
40
|
+
export const PauseSchema = z.object({
|
|
41
|
+
id: z.string(), agentId: z.string(), workspaceId: z.string().optional(), provider: z.string(), title: z.string().nullable(),
|
|
42
|
+
windowId: z.string(), anchorResetsAt: z.string().nullable(),
|
|
43
|
+
pausedAt: z.string(), lastUserMessageAt: z.string().nullable(), turnId: z.string().nullable(),
|
|
44
|
+
phase: z.enum(["pausing", "waiting", "resuming", "resumed", "cancelled", "attention"]),
|
|
45
|
+
note: z.string().nullable(), settledAt: z.string().nullable(),
|
|
46
|
+
wasQuotaFailure: z.boolean().default(false),
|
|
47
|
+
resumedInPlace: z.boolean().optional(),
|
|
48
|
+
manualResume: z.boolean().optional(),
|
|
49
|
+
dismissed: z.boolean().optional(),
|
|
50
|
+
restartRecovery: z.boolean().optional(),
|
|
51
|
+
daemonIdentity: z.string().optional(),
|
|
52
|
+
closedOnRestart: z.boolean().optional(),
|
|
53
|
+
releasedForUnload: z.boolean().optional(),
|
|
54
|
+
demo: z.boolean().optional(),
|
|
55
|
+
stoppedByUser: z.boolean().optional(),
|
|
56
|
+
notifications: z.array(NotificationSchema).optional(),
|
|
57
|
+
suspension: z.object({
|
|
58
|
+
rootPid: z.number().int().positive(),
|
|
59
|
+
processes: z.array(z.object({ pid: z.number().int().positive(), startTime: z.string() })),
|
|
60
|
+
}).optional(),
|
|
61
|
+
});
|
|
62
|
+
export type Pause = z.infer<typeof PauseSchema>;
|
|
63
|
+
export const StateSchema = z.object({
|
|
64
|
+
version: z.literal(1),
|
|
65
|
+
overrides: z.record(z.string(), z.boolean()),
|
|
66
|
+
pauses: z.array(PauseSchema),
|
|
67
|
+
labelledWorkspaces: z.array(z.string()).optional(),
|
|
68
|
+
workspaceLabelName: z.string().optional(),
|
|
69
|
+
});
|
|
70
|
+
export type State = z.infer<typeof StateSchema>;
|
|
71
|
+
export const isPending = (pause: Pause) => ["pausing", "waiting", "resuming", "attention"].includes(pause.phase);
|
|
72
|
+
export const protects = (policy: Policy, overrides: State["overrides"], id: string) => overrides[id] ?? policy.protectByDefault;
|
|
73
|
+
|
|
74
|
+
export interface Agent {
|
|
75
|
+
id: string; provider: string; status: string; title?: string | null;
|
|
76
|
+
workspaceId?: string; lastUserMessageAt?: string | null; archivedAt?: string | null;
|
|
77
|
+
activeTurn?: { turnId: string } | null;
|
|
78
|
+
pendingPermissions?: readonly unknown[];
|
|
79
|
+
labels?: Record<string, string>;
|
|
80
|
+
lastError?: string;
|
|
81
|
+
}
|
|
82
|
+
export interface UsageWindow {
|
|
83
|
+
id: string; usedPct?: number | null; remainingPct?: number | null; resetsAt?: string | null;
|
|
84
|
+
}
|
|
85
|
+
export interface ProviderUsage {
|
|
86
|
+
providerId: string; status: string; fetchedAt?: string | null; windows: readonly UsageWindow[];
|
|
87
|
+
}
|
|
88
|
+
export interface Usage {
|
|
89
|
+
fetchedAt: string; providers: readonly ProviderUsage[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function timestamp(value: string | null | undefined): number | null {
|
|
93
|
+
if (!value) return null;
|
|
94
|
+
const ms = Date.parse(value);
|
|
95
|
+
return Number.isFinite(ms) ? ms : null;
|
|
96
|
+
}
|
|
97
|
+
export function usedPct(window: UsageWindow): number | null {
|
|
98
|
+
if (typeof window.usedPct === "number" && Number.isFinite(window.usedPct)) return window.usedPct;
|
|
99
|
+
if (typeof window.remainingPct === "number" && Number.isFinite(window.remainingPct)) return 100 - window.remainingPct;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
export function sessionWindow(provider: ProviderUsage | undefined, policy: Policy): UsageWindow | undefined {
|
|
103
|
+
if (provider?.status !== "available") return undefined;
|
|
104
|
+
return policy.windowIds.map(id => provider.windows.find(window => window.id === id)).find(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Reused from paseo-defer's scheduler: upstream re-reads of one window differ
|
|
108
|
+
// by milliseconds. Only a materially later reset is a rollover.
|
|
109
|
+
export const SAME_WINDOW_MS = 60_000;
|
|
110
|
+
export function resetReady(pause: Pause, usage: Usage, policy: Policy, now: number): boolean {
|
|
111
|
+
const provider = usage.providers.find(p => p.providerId === pause.provider);
|
|
112
|
+
if (provider?.status !== "available") return false;
|
|
113
|
+
const window = provider.windows.find(w => w.id === pause.windowId);
|
|
114
|
+
if (!window) return false;
|
|
115
|
+
const pct = usedPct(window);
|
|
116
|
+
if (pct === null || pct >= policy.thresholdPct) return false;
|
|
117
|
+
// A later reset with an old cached percentage must never release a pause.
|
|
118
|
+
const fetched = timestamp(provider.fetchedAt ?? usage.fetchedAt);
|
|
119
|
+
const paused = timestamp(pause.pausedAt)!;
|
|
120
|
+
if (fetched === null || fetched <= paused || fetched > now + SAME_WINDOW_MS) return false;
|
|
121
|
+
const anchor = timestamp(pause.anchorResetsAt);
|
|
122
|
+
const current = timestamp(window.resetsAt);
|
|
123
|
+
const grace = policy.resetGraceSeconds * 1000;
|
|
124
|
+
if (anchor === null) return fetched >= paused + grace;
|
|
125
|
+
// Millisecond jitter is the same window; a real rollover moves it hours.
|
|
126
|
+
if (current !== null && current - anchor > SAME_WINDOW_MS) return fetched >= paused + grace;
|
|
127
|
+
return now >= anchor + grace && fetched >= anchor + grace;
|
|
128
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { isPending, timestamp, type Pause, type Policy } from "./policy.ts";
|
|
2
|
+
|
|
3
|
+
export function pausedSessions(pauses: readonly Pause[], query = "") {
|
|
4
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
5
|
+
return pauses.filter(isPending).filter(p => !needle ||
|
|
6
|
+
`${p.title ?? "Untitled session"} ${p.provider} ${p.agentId} ${p.suspension ? "native subagents" : "interrupted turn"}`.toLocaleLowerCase().includes(needle))
|
|
7
|
+
.sort((a, b) => {
|
|
8
|
+
if ((a.phase === "attention") !== (b.phase === "attention")) return a.phase === "attention" ? -1 : 1;
|
|
9
|
+
return (timestamp(a.anchorResetsAt) ?? Infinity) - (timestamp(b.anchorResetsAt) ?? Infinity) || a.pausedAt.localeCompare(b.pausedAt);
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function pausePresentation(pause: Pause, policy: Policy, now = Date.now()) {
|
|
13
|
+
const reset = timestamp(pause.anchorResetsAt);
|
|
14
|
+
const resetLabel = pause.demo ? "Waiting for your test action" : reset === null ? "Reset time unavailable" : reset <= now
|
|
15
|
+
? "Reset due · waiting for fresh usage"
|
|
16
|
+
: `Reset ${new Date(reset).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`;
|
|
17
|
+
const state = pause.phase === "attention" ? "Needs attention" : pause.phase === "pausing" ? "Pausing"
|
|
18
|
+
: pause.phase === "resuming" ? "Resuming" : pause.demo ? "Demo · resume when ready" : !policy.enabled ? "Guard off · continuation on hold"
|
|
19
|
+
: !policy.autoResume ? "Automatic resume off" : "Will resume automatically after reset";
|
|
20
|
+
return {
|
|
21
|
+
title: pause.title?.trim() || "Untitled session", state, resetLabel,
|
|
22
|
+
coverage: pause.phase === "attention" ? "Check session state" : pause.suspension ? "Includes native subagents" : pause.restartRecovery ? "Saved conversation · waiting after restart" : "Interrupted turn",
|
|
23
|
+
actionLabel: pause.suspension ? "Release and turn off" : "Cancel continuation",
|
|
24
|
+
actionHint: pause.suspension ? "Resume these workers now and exclude this session from protection."
|
|
25
|
+
: "Leave this session for you to continue manually.",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The host master switch always outranks a session's saved preference. */
|
|
30
|
+
export function callerProtection(enabled: boolean, protectByDefault: boolean, override: boolean | undefined) {
|
|
31
|
+
return { enabled, protected: enabled && (override ?? protectByDefault), override: override ?? null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What an agent is told about Session Guard, in the words it reads.
|
|
36
|
+
*
|
|
37
|
+
* Read-only by design: nothing here lets an agent opt itself out or resume a pause,
|
|
38
|
+
* so it says what will happen rather than offering a way round it.
|
|
39
|
+
*/
|
|
40
|
+
export function agentGuardSummary(status: {
|
|
41
|
+
governor: "off" | "active" | "deferred" | "error";
|
|
42
|
+
protected: boolean; thresholdPct: number; autoResume: boolean;
|
|
43
|
+
pauses: readonly { phase: Pause["phase"]; resetsAt: string | null }[];
|
|
44
|
+
}): string[] {
|
|
45
|
+
if (status.governor === "deferred") return ["Session Guard: run by another plugin on this host (likely the standalone Session Guard); this session's protection is not visible from here."];
|
|
46
|
+
if (status.governor === "error") return ["Session Guard: status unavailable."];
|
|
47
|
+
if (status.governor === "off" || !status.protected) return ["Session Guard: off for this session. Reaching the session limit will not pause it here; the provider will simply refuse requests until reset."];
|
|
48
|
+
const pending = status.pauses.find(pause => ["pausing", "waiting", "resuming", "attention"].includes(pause.phase));
|
|
49
|
+
if (pending) {
|
|
50
|
+
return [`Session Guard: this session is paused for the session limit (${pending.phase})${pending.resetsAt ? `; the window resets ${pending.resetsAt}` : ""}.`];
|
|
51
|
+
}
|
|
52
|
+
return [`Session Guard: on. At ${status.thresholdPct}% of the session window Paseo pauses this session${status.autoResume
|
|
53
|
+
? " and continues it in this same conversation once the window resets — no need to stop early or hand off because of the session limit."
|
|
54
|
+
: "; a person continues it after the reset."}`];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type GovernorView = { governor: "off" | "active" | "deferred" | "error"; standalone: boolean | null; error: string | null };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Why this plugin is not pausing anything, for the top of each Session Guard view.
|
|
61
|
+
* Null while it is the host's governor.
|
|
62
|
+
*/
|
|
63
|
+
export function governorNotice(status: GovernorView): { title: string; hint: string } | null {
|
|
64
|
+
if (status.governor === "active") return null;
|
|
65
|
+
if (status.governor === "off") return { title: "Session Guard is off", hint: "Nothing is paused from Smart Session. Turn it on in Session Guard settings to pause sessions at the provider's session limit." };
|
|
66
|
+
if (status.governor === "deferred" && status.standalone !== false) {
|
|
67
|
+
return { title: "The standalone Session Guard plugin is in charge", hint: status.error ?? "Disable or uninstall Session Guard to hand over; its per-session choices and waiting pauses carry over." };
|
|
68
|
+
}
|
|
69
|
+
if (status.governor === "deferred") return { title: "Another process is running Session Guard", hint: status.error ?? "Nothing is paused from here until it stops." };
|
|
70
|
+
return { title: "Session Guard is not running", hint: status.error ?? "It will retry automatically." };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Who a pause in the shared state belongs to, when it is not this plugin.
|
|
75
|
+
*
|
|
76
|
+
* Both plugins read one state file, so a list here can show pauses only the other
|
|
77
|
+
* plugin can act on. Null means this plugin governs and can act on them.
|
|
78
|
+
*/
|
|
79
|
+
export function pauseOwnerNote(status: GovernorView): string | null {
|
|
80
|
+
if (status.governor === "active") return null;
|
|
81
|
+
if (status.governor === "off") return "Saved by the standalone Session Guard plugin. Smart Session takes it over once Session Guard is on here and the standalone plugin is disabled or uninstalled.";
|
|
82
|
+
if (status.governor === "deferred" && status.standalone !== false) return "Managed by the standalone Session Guard plugin; change it there.";
|
|
83
|
+
if (status.governor === "deferred") return "Managed by another Session Guard process; it can be changed only from there.";
|
|
84
|
+
return "Session Guard is not running here, so this pause cannot be changed until it recovers.";
|
|
85
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const TimelineDataSchema = z.object({
|
|
4
|
+
pauseId: z.string(), event: z.enum(["paused", "resumed", "released", "attention", "recovery"]),
|
|
5
|
+
at: z.string(), provider: z.string(), native: z.boolean(), autoResume: z.boolean(),
|
|
6
|
+
resetAt: z.string().nullable(), usedPct: z.number().nullable(), note: z.string().nullable(),
|
|
7
|
+
manualResume: z.boolean().optional(), demo: z.boolean().optional(), dismissed: z.boolean().optional(),
|
|
8
|
+
});
|
|
9
|
+
export type TimelineData = z.infer<typeof TimelineDataSchema>;
|
|
10
|
+
export const NotificationSchema = z.object({
|
|
11
|
+
id: z.string(), data: TimelineDataSchema, delivery: z.enum(["pending", "sending", "sent"]),
|
|
12
|
+
});
|
|
13
|
+
export type Notification = z.infer<typeof NotificationSchema>;
|
|
14
|
+
|
|
15
|
+
export function timelinePresentation(data: TimelineData) {
|
|
16
|
+
const provider = data.provider === "claude" ? "Claude" : data.provider === "codex" ? "Codex" : data.provider;
|
|
17
|
+
if (data.event === "released" && data.dismissed) return { title: "Pause dismissed", icon: "ShieldOff", tone: "muted" as const,
|
|
18
|
+
detail: "No message was sent. Protection is off for this session." + (data.native ? " Existing workers were released; their current work may continue." : "") };
|
|
19
|
+
if (data.event === "resumed") return { title: "Session resumed", icon: "Play", tone: "success" as const,
|
|
20
|
+
detail: data.manualResume ? "Resumed by you. Protection is off for this session; restore it from the composer pill when ready."
|
|
21
|
+
: data.native ? "Session and native subagents resumed after the quota reset." : "A continuation was sent after the quota reset." };
|
|
22
|
+
if (data.event === "released") return { title: data.native ? "Session released" : "Automatic resume cancelled", icon: "ShieldOff", tone: "muted" as const,
|
|
23
|
+
detail: data.note ?? (data.native ? "Suspended workers were released." : "Continue this session manually when ready.") };
|
|
24
|
+
if (data.event === "attention") return { title: "Pause needs attention", icon: "TriangleAlert", tone: "danger" as const,
|
|
25
|
+
detail: data.note ?? "Check this session before continuing manually." };
|
|
26
|
+
if (data.event === "recovery") return { title: "Waiting after Paseo restart", icon: "Pause", tone: "warning" as const,
|
|
27
|
+
detail: `The original workers ended, but this pause is saved. ${data.autoResume ? "Session Guard will continue this conversation after the quota resets." : "Automatic resume is off; continue manually when ready."} Your workspace and conversation remain in place.` };
|
|
28
|
+
if (data.demo) return { title: "Session Guard demo paused", icon: "Pause", tone: "warning" as const,
|
|
29
|
+
detail: "This empty test session is suspended so you can try Resume now. No provider quota was changed, and no task was sent. It will wait for you to resume it." };
|
|
30
|
+
const reset = data.resetAt ? Date.parse(data.resetAt) : NaN;
|
|
31
|
+
const resetLabel = Number.isFinite(reset) ? `Expected reset ${new Date(reset).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}.` : "The provider has not reported a reset time yet.";
|
|
32
|
+
return { title: "Paused for session limit", icon: "Pause", tone: "warning" as const,
|
|
33
|
+
detail: `${provider}${data.usedPct === null ? "" : ` · ${data.usedPct}% used`}. ${data.native ? "This session and its native subagents are paused." : "The active turn was interrupted."} ${resetLabel} ${data.autoResume ? "Automatic resume was scheduled after reset confirmation." : "Automatic resume was off; continue manually when ready."}` };
|
|
34
|
+
}
|
package/shared/lifecycle.ts
CHANGED
|
@@ -15,3 +15,21 @@ export type Teardown = () => void | Promise<void>;
|
|
|
15
15
|
* exits and the plugin wedges at "Stopping" for the life of the daemon.
|
|
16
16
|
*/
|
|
17
17
|
export const lifecycle: { teardowns: Teardown[] } = { teardowns: [] };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Runs every teardown even when one fails, then reports the first failure.
|
|
21
|
+
*
|
|
22
|
+
* One feature's cleanup throwing must not leave another's timer running: the
|
|
23
|
+
* subprocess would never exit and Paseo's stop step would hang.
|
|
24
|
+
*/
|
|
25
|
+
export async function runTeardowns(teardowns: readonly Teardown[]): Promise<void> {
|
|
26
|
+
let failure: unknown = null;
|
|
27
|
+
for (const teardown of teardowns) {
|
|
28
|
+
try {
|
|
29
|
+
await teardown();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
failure ??= error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (failure !== null) throw failure;
|
|
35
|
+
}
|