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.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Session Guard's wiring: settings, the governor loop, RPCs and lifecycle hooks.
3
+ *
4
+ * The engine (`./engine.ts`) decides what to pause and when to continue; this file
5
+ * decides whether this process may run it at all. Only one process per host may:
6
+ *
7
+ * - With the feature off, nothing here holds the lock or touches a session.
8
+ * - While the standalone `paseo-session-guard` plugin is configured and enabled, this
9
+ * one never governs, even if it could take the lock: that plugin's teardown resumes
10
+ * every suspended session in the shared state whether or not it owns it
11
+ * (`./standalone.ts`). If it becomes enabled while this one governs, this one
12
+ * releases its suspended workers, gives the lock back and waits.
13
+ * - Otherwise it takes `governor.lock` in the shared state directory (`./store.ts`).
14
+ * If another live process holds it, this one defers and retries on every poll.
15
+ * - Turned off again, it stops pausing at once, releases suspended workers on its next
16
+ * pass, and gives the lock back once no pause is left waiting on it.
17
+ */
18
+
19
+ import type { PluginHandlerContext, PluginServerContext } from "@getpaseo/plugin/server";
20
+
21
+ import {
22
+ agentStatusRpc,
23
+ cancelResumeRpc,
24
+ demoPauseRpc,
25
+ dismissPauseRpc,
26
+ guardPreferences,
27
+ resumeNowRpc,
28
+ runtimeRpc,
29
+ setProtectionRpc,
30
+ statusRpc,
31
+ type Governor,
32
+ } from "../../shared/guard/contracts.ts";
33
+ import { isPending, PolicySchema, sessionWindow, usedPct, type Policy, type State, type Usage } from "../../shared/guard/policy.ts";
34
+ import { agentGuardSummary, callerProtection } from "../../shared/guard/presentation.ts";
35
+ import { withDaemon } from "../daemon.ts";
36
+ import { freshestUsage } from "../store.ts";
37
+ import type { Connect, GuardClient } from "./client.ts";
38
+ import { Engine } from "./engine.ts";
39
+ import { WorkspaceLabels } from "./labels.ts";
40
+ import { LinuxProcessControl, type ProcessControl } from "./processes.ts";
41
+ import { standaloneEnabled } from "./standalone.ts";
42
+ import { guardDataDir, LockHeldError, Store } from "./store.ts";
43
+ import { timelinePublisher } from "./timeline.ts";
44
+ import { withRecordedUsage } from "./usage.ts";
45
+
46
+ /** The install id this plugin's own RPCs are reached through. */
47
+ const PLUGIN_ID = "smart-session";
48
+
49
+ /** How often to retry the lock while another process holds it. */
50
+ const DEFERRED_RETRY_SECONDS = 15;
51
+
52
+ const STANDALONE_MESSAGE =
53
+ "The standalone Session Guard plugin is enabled on this host, so Smart Session leaves session protection to it and pauses nothing. " +
54
+ "Disable or uninstall Session Guard to hand over; its per-session choices and waiting pauses carry over.";
55
+ const HANDED_BACK_MESSAGE =
56
+ "The standalone Session Guard plugin was enabled while Smart Session was protecting this host, so Smart Session released its paused sessions and stopped. " +
57
+ "Disable or uninstall Session Guard to hand back to Smart Session, or reload Session Guard to let it take over.";
58
+ const LOCK_HELD_MESSAGE =
59
+ "Another process is already running Session Guard on this host, most likely a second Smart Session install. Nothing is paused from here until it stops.";
60
+ const UNCHECKED_MESSAGE = "Session Guard could not check which plugins are installed, so it is not pausing anything yet. It will retry.";
61
+ const OFF_MESSAGE = "Session Guard is off on this host. Turn it on in Smart Session's Session Guard settings.";
62
+
63
+ /** The daemon connection and process control, replaceable only by tests. */
64
+ export interface GuardDependencies {
65
+ connect?: Connect;
66
+ processes?: ProcessControl;
67
+ }
68
+
69
+ export function contributeGuard(server: PluginServerContext, options: GuardDependencies = {}): () => Promise<void> {
70
+ const settings = server.registerSettings(guardPreferences);
71
+ let settingsError: string | null = null;
72
+ const readPolicy = async (): Promise<Policy> => {
73
+ const result = await settings.read();
74
+ if (result.status !== "ready") throw new Error("Session Guard settings are invalid.");
75
+ return PolicySchema.parse(result.values);
76
+ };
77
+
78
+ // The guard's own connections, so teardown can close one that is mid-call without
79
+ // touching a delivery the compaction governor has in flight on another socket.
80
+ const open = new Set<{ close(): Promise<void> }>();
81
+ let disposed = false;
82
+ const daemon: Connect = options.connect ?? ((work) => withDaemon((client) => work(client as unknown as GuardClient)));
83
+ const connect: Connect = (work) => {
84
+ if (disposed) return Promise.reject(new Error("Session Guard is stopping."));
85
+ return daemon(async (client) => {
86
+ const closable = client as unknown as { close(): Promise<void> };
87
+ open.add(closable);
88
+ try {
89
+ // The same Paseo client the recorder uses, read through the guard's typed view.
90
+ return await work(client);
91
+ } finally {
92
+ open.delete(closable);
93
+ }
94
+ });
95
+ };
96
+
97
+ // Timeline appends need the host-owned API, which only an RPC handler receives.
98
+ let runtime: PluginHandlerContext["paseo"] | null = null;
99
+ server.handle(runtimeRpc, (_, context) => {
100
+ runtime = context.paseo;
101
+ return { ok: true };
102
+ });
103
+ const publish = timelinePublisher(async () => {
104
+ if (!runtime) {
105
+ await connect(async (client) => {
106
+ if (!client.invokePluginRpc) throw new Error("Plugin runtime API is unavailable.");
107
+ await client.invokePluginRpc(PLUGIN_ID, runtimeRpc.name, {});
108
+ });
109
+ }
110
+ if (!runtime) throw new Error("Plugin runtime API is unavailable.");
111
+ return runtime;
112
+ });
113
+
114
+ const store = new Store(guardDataDir());
115
+ const readUsage = async (client: GuardClient): Promise<Usage> =>
116
+ withRecordedUsage(await client.listProviderUsage(), await freshestUsage().catch(() => null));
117
+ const engine = new Engine(store, readPolicy, connect, Date.now, options.processes ?? new LinuxProcessControl(), publish, readUsage);
118
+ const labels = new WorkspaceLabels(store, (work) => engine.exclusive(work), connect, readPolicy);
119
+
120
+ let governor: Governor = "off";
121
+ let lockError: string | null = null;
122
+ let release: (() => Promise<void>) | null = null;
123
+
124
+ const claim = async () => {
125
+ try {
126
+ release = await store.lock();
127
+ } catch (error) {
128
+ governor = error instanceof LockHeldError ? "deferred" : "error";
129
+ lockError = governor === "deferred" ? LOCK_HELD_MESSAGE : "Session Guard could not take its lock. Check its saved state and try again.";
130
+ return;
131
+ }
132
+ try {
133
+ await engine.recover();
134
+ governor = "active";
135
+ lockError = null;
136
+ } catch {
137
+ governor = "error";
138
+ lockError = "Session Guard could not read its saved state. Restore state.json before continuing.";
139
+ const unlock = release;
140
+ release = null;
141
+ await unlock?.();
142
+ }
143
+ };
144
+
145
+ /** Gives the lock back, but only with nothing left that needs an owner. */
146
+ const standDown = async () => {
147
+ const idle = await engine.exclusive(async () => !(await store.read()).pauses.some(isPending));
148
+ if (!idle) return;
149
+ await engine.deliverTimeline();
150
+ await labels.refresh();
151
+ await engine.exclusive(async () => {
152
+ if ((await store.read()).pauses.some(isPending)) return;
153
+ governor = "off";
154
+ const unlock = release;
155
+ release = null;
156
+ await unlock?.();
157
+ });
158
+ };
159
+
160
+ /** True or false from the daemon's plugin list; null when it could not be read. */
161
+ let standalone: boolean | null = null;
162
+ const checkStandalone = async () => {
163
+ try {
164
+ standalone = await connect(async (client) => (client.listPlugins ? standaloneEnabled(await client.listPlugins()) : false));
165
+ } catch {
166
+ standalone = null;
167
+ }
168
+ };
169
+
170
+ /** Steps aside for an enabled standalone plugin: nothing frozen is left without an owner. */
171
+ const handBack = async () => {
172
+ governor = "deferred"; // Refuse new actions before releasing anything.
173
+ lockError = HANDED_BACK_MESSAGE;
174
+ try {
175
+ await engine.releaseWorkers();
176
+ } catch {
177
+ lockError = `${HANDED_BACK_MESSAGE} Some paused sessions could not be released; check them.`;
178
+ }
179
+ await engine.exclusive(async () => {
180
+ const unlock = release;
181
+ release = null;
182
+ await unlock?.();
183
+ });
184
+ };
185
+
186
+ const pass = async (): Promise<Policy | null> => {
187
+ let policy: Policy | null = null;
188
+ try {
189
+ policy = await readPolicy();
190
+ settingsError = null;
191
+ } catch {
192
+ settingsError = "Session Guard settings are invalid. Reset them in Session Guard settings.";
193
+ }
194
+ if (!policy?.enabled && governor !== "active") {
195
+ governor = "off";
196
+ lockError = null;
197
+ return policy;
198
+ }
199
+ await checkStandalone();
200
+ if (governor === "active") {
201
+ if (standalone === true) {
202
+ await handBack();
203
+ return policy;
204
+ }
205
+ } else if (standalone === true) {
206
+ governor = "deferred";
207
+ // Keep the fuller explanation if this process just handed the host back.
208
+ if (!lockError?.startsWith(HANDED_BACK_MESSAGE)) lockError = STANDALONE_MESSAGE;
209
+ return policy;
210
+ } else if (standalone === null) {
211
+ governor = "error";
212
+ lockError = UNCHECKED_MESSAGE;
213
+ return policy;
214
+ } else {
215
+ await claim();
216
+ }
217
+ if (governor !== "active") return policy;
218
+ await engine.tick();
219
+ void engine.deliverTimeline();
220
+ void labels.refresh();
221
+ if (policy !== null && !policy.enabled) await standDown();
222
+ return policy;
223
+ };
224
+
225
+ let stopped = false;
226
+ let timer: ReturnType<typeof setTimeout> | null = null;
227
+ let current: Promise<void> | null = null;
228
+ let again = false;
229
+ const refresh = (): Promise<void> => {
230
+ if (stopped) return Promise.resolve();
231
+ if (current) {
232
+ again = true; // A settings change mid-pass must not wait out a whole interval.
233
+ return current;
234
+ }
235
+ if (timer !== null) clearTimeout(timer);
236
+ timer = null;
237
+ current = (async () => {
238
+ let policy: Policy | null = null;
239
+ try {
240
+ policy = await pass();
241
+ } catch {
242
+ // Each step records its own error for the status RPC; the loop must survive.
243
+ }
244
+ current = null;
245
+ if (stopped) return;
246
+ if (again) {
247
+ again = false;
248
+ void refresh();
249
+ return;
250
+ }
251
+ // Off needs no timer: turning it on arrives through the settings subscription.
252
+ if (governor === "active") timer = setTimeout(() => void refresh(), (policy?.pollSeconds ?? 15) * 1000);
253
+ else if (governor !== "off") timer = setTimeout(() => void refresh(), DEFERRED_RETRY_SECONDS * 1000);
254
+ })();
255
+ return current;
256
+ };
257
+
258
+ const requireOwner = async () => {
259
+ if (current) await current;
260
+ if (stopped) throw new Error("Session Guard is stopping.");
261
+ if (governor !== "active") throw new Error(lockError ?? OFF_MESSAGE);
262
+ };
263
+ /** Readable whether or not this process governs: the file is replaced atomically. */
264
+ const readState = async (): Promise<State> => (governor === "active" ? engine.exclusive(() => store.read()) : store.read());
265
+ const readPolicyOrDefaults = () => readPolicy().catch(() => PolicySchema.parse({}));
266
+ const emptyState = (): State => ({ version: 1, overrides: {}, pauses: [] });
267
+ const providerRows = (policy: Policy) =>
268
+ (governor === "active" ? (engine.usage?.providers ?? []) : []).map((provider) => {
269
+ const window = sessionWindow(provider, policy);
270
+ return { providerId: provider.providerId, usedPct: window ? usedPct(window) : null, resetsAt: window?.resetsAt ?? null };
271
+ });
272
+ const afterAction = () => {
273
+ void engine.deliverTimeline();
274
+ void labels.refresh();
275
+ };
276
+
277
+ server.handle(statusRpc, async (_, context) => {
278
+ runtime = context.paseo;
279
+ const policy = await readPolicyOrDefaults();
280
+ let state = emptyState();
281
+ let stateError: string | null = null;
282
+ try {
283
+ state = await readState();
284
+ } catch (error) {
285
+ stateError = error instanceof Error ? error.message : String(error);
286
+ }
287
+ const active = governor === "active";
288
+ return {
289
+ governor,
290
+ standalone: governor === "off" ? null : standalone,
291
+ checkedAt: active ? engine.checkedAt : null,
292
+ error: lockError ?? settingsError ?? stateError ?? (active ? (engine.error ?? engine.timelineError ?? labels.error) : null),
293
+ pauses: state.pauses,
294
+ overrides: state.overrides,
295
+ policy,
296
+ providers: providerRows(policy),
297
+ };
298
+ });
299
+
300
+ server.handle(agentStatusRpc, async ({ agentId }) => {
301
+ const policy = await readPolicyOrDefaults();
302
+ const state = await readState().catch(emptyState);
303
+ const protection = callerProtection(governor === "active" && policy.enabled, policy.protectByDefault, state.overrides[agentId]);
304
+ const pauses = state.pauses
305
+ .filter((pause) => pause.agentId === agentId)
306
+ .map((pause) => ({ id: pause.id, phase: pause.phase, provider: pause.provider, resetsAt: pause.anchorResetsAt }));
307
+ return {
308
+ governor,
309
+ ...protection,
310
+ thresholdPct: policy.thresholdPct,
311
+ autoResume: policy.autoResume,
312
+ pauses,
313
+ providers: providerRows(policy),
314
+ summary: agentGuardSummary({ governor, protected: protection.protected, thresholdPct: policy.thresholdPct, autoResume: policy.autoResume, pauses }),
315
+ };
316
+ });
317
+
318
+ server.handle(setProtectionRpc, async ({ agentId, enabled }) => {
319
+ await requireOwner();
320
+ await engine.setProtection(agentId, enabled);
321
+ afterAction();
322
+ void refresh();
323
+ return { ok: true };
324
+ });
325
+ server.handle(cancelResumeRpc, async ({ agentId }) => {
326
+ await requireOwner();
327
+ await engine.cancelResume(agentId);
328
+ afterAction();
329
+ return { ok: true };
330
+ });
331
+ server.handle(resumeNowRpc, async ({ agentId, pauseId }) => {
332
+ await requireOwner();
333
+ await engine.resumeNow(agentId, pauseId);
334
+ afterAction();
335
+ return { ok: true };
336
+ });
337
+ server.handle(dismissPauseRpc, async ({ agentId, pauseId }) => {
338
+ await requireOwner();
339
+ await engine.dismissPause(agentId, pauseId);
340
+ afterAction();
341
+ return { ok: true };
342
+ });
343
+ server.handle(demoPauseRpc, async ({ agentId }) => {
344
+ await requireOwner();
345
+ await engine.pauseDemo(agentId);
346
+ afterAction();
347
+ return { ok: true };
348
+ });
349
+
350
+ const removeSettings = settings.subscribe(() => {
351
+ void refresh();
352
+ });
353
+ const removeTurnStarted = server.on("agent.turn_started", () => {
354
+ if (governor !== "off") void refresh();
355
+ });
356
+ const removeTurnEnded = server.on("agent.turn_ended", (event) => {
357
+ if (governor !== "active") return;
358
+ // Never await a governor tick inside a lifecycle hook: cancelAgent itself causes
359
+ // turn_ended, and awaiting both would deadlock.
360
+ if (!stopped) void engine.turnEnded(event.agent.id, event.turnId, event.outcome.kind).catch(() => undefined);
361
+ if (event.outcome.kind === "failed") engine.quotaFailure(event.agent.id, event.outcome.error.message, event.turnId);
362
+ void refresh();
363
+ });
364
+
365
+ void refresh();
366
+
367
+ return async () => {
368
+ stopped = true;
369
+ if (timer !== null) clearTimeout(timer);
370
+ timer = null;
371
+ removeSettings();
372
+ removeTurnStarted();
373
+ removeTurnEnded();
374
+ disposed = true;
375
+ await Promise.allSettled([...open].map((client) => client.close()));
376
+ await current?.catch(() => undefined);
377
+ try {
378
+ // Only the owner may touch suspended workers: a deferring process must leave the
379
+ // other governor's pauses exactly as they are.
380
+ if (governor === "active") {
381
+ try {
382
+ await engine.stop();
383
+ } finally {
384
+ await labels.stop();
385
+ }
386
+ }
387
+ } finally {
388
+ // Always give the lock back, even when a release failed: this process is exiting,
389
+ // and a held lock would only keep the next governor waiting.
390
+ const unlock = release;
391
+ release = null;
392
+ await unlock?.();
393
+ }
394
+ };
395
+ }
@@ -0,0 +1,77 @@
1
+ import type { Connect } from "./client.ts";
2
+ import type { Store } from "./store.ts";
3
+ import type { Policy } from "../../shared/guard/policy.ts";
4
+
5
+ export const PAUSED_WORKSPACE_LABEL = { name: "Paused", color: "amber" as const };
6
+ type Exclusive = <T>(work: () => Promise<T>) => Promise<T>;
7
+
8
+ /** Label delivery never holds the governor's action lock during daemon I/O. */
9
+ export class WorkspaceLabels {
10
+ private store: Store;
11
+ private exclusive: Exclusive;
12
+ private connect: Connect;
13
+ private policy: () => Promise<Policy>;
14
+ private pending: Promise<void> | null = null;
15
+ private stopped = false;
16
+ error: string | null = null;
17
+ constructor(store: Store, exclusive: Exclusive, connect: Connect, policy: () => Promise<Policy>) {
18
+ this.store = store; this.exclusive = exclusive; this.connect = connect; this.policy = policy;
19
+ }
20
+ refresh(): Promise<void> {
21
+ if (this.stopped) return Promise.resolve();
22
+ if (this.pending) return this.pending;
23
+ this.pending = this.reconcile().catch(() => {
24
+ this.error = "Workspace pause labels are awaiting delivery. Session protection is still active; labels will retry.";
25
+ }).finally(() => { this.pending = null; });
26
+ return this.pending;
27
+ }
28
+ private async reconcile() {
29
+ const pauses = await this.exclusive(async () => (await this.store.read()).pauses.filter(p => ["waiting", "resuming"].includes(p.phase)));
30
+ const assignments = new Map<string, string>();
31
+ if (pauses.length) await this.connect(async client => {
32
+ for (const pause of pauses) {
33
+ const agent = (await client.fetchAgent(pause.agentId))?.agent;
34
+ if (agent?.workspaceId) assignments.set(pause.id, agent.workspaceId);
35
+ }
36
+ });
37
+ await this.exclusive(async () => {
38
+ const state = await this.store.read(); let changed = false;
39
+ for (const pause of state.pauses) {
40
+ const workspaceId = assignments.get(pause.id);
41
+ if (workspaceId && pause.workspaceId !== workspaceId) { pause.workspaceId = workspaceId; changed = true; }
42
+ }
43
+ if (changed) await this.store.write(state);
44
+ });
45
+ // Re-read after every acknowledgement: another session may pause or resume
46
+ // while a label RPC is in flight. Journal ownership before an assignment so
47
+ // a lost acknowledgement or crash still lets us remove the label later.
48
+ const acknowledged = new Set<string>();
49
+ for (let i = 0; i < 400 && !this.stopped; i++) {
50
+ const policy = await this.policy();
51
+ const action = await this.exclusive(async () => {
52
+ const state = await this.store.read();
53
+ const desired = new Set(policy.workspaceLabels ? state.pauses.filter(p => ["waiting", "resuming"].includes(p.phase)).map(p => p.workspaceId).filter((id): id is string => !!id) : []);
54
+ const managed = state.labelledWorkspaces ?? [];
55
+ const name = state.workspaceLabelName ?? "Paused by Session Guard";
56
+ const remove = managed.find(id => name !== PAUSED_WORKSPACE_LABEL.name || !desired.has(id));
57
+ if (remove) return { workspaceId: remove, assigned: false, name };
58
+ const add = [...desired].find(id => !acknowledged.has(id));
59
+ if (!add) return null;
60
+ if (!managed.includes(add)) { state.labelledWorkspaces = [...managed, add]; state.workspaceLabelName = PAUSED_WORKSPACE_LABEL.name; await this.store.write(state); }
61
+ return { workspaceId: add, assigned: true, name: PAUSED_WORKSPACE_LABEL.name };
62
+ });
63
+ if (!action) { this.error = null; return; }
64
+ await this.connect(async client => {
65
+ if (!client.setWorkspaceLabel) throw new Error("Workspace labels are unavailable on this Paseo host.");
66
+ await client.setWorkspaceLabel({ workspaceId: action.workspaceId, assigned: action.assigned, label: { ...PAUSED_WORKSPACE_LABEL, name: action.name } });
67
+ });
68
+ if (action.assigned) acknowledged.add(action.workspaceId);
69
+ else await this.exclusive(async () => {
70
+ const state = await this.store.read();
71
+ state.labelledWorkspaces = (state.labelledWorkspaces ?? []).filter(id => id !== action.workspaceId);
72
+ await this.store.write(state);
73
+ });
74
+ }
75
+ }
76
+ async stop() { this.stopped = true; await this.pending; }
77
+ }
@@ -0,0 +1,147 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import type { Agent, Pause } from "../../shared/guard/policy.ts";
3
+
4
+ export type Suspension = NonNullable<Pause["suspension"]>;
5
+ type Identity = Suspension["processes"][number];
6
+ type Snapshot = Identity & { ppid: number; state: string };
7
+ export interface ProcessControl {
8
+ discover(agent: Agent, daemonPid: number): Promise<Suspension | null>;
9
+ freeze(suspension: Suspension, journal: () => Promise<void>): Promise<void>;
10
+ resume(suspension: Suspension): Promise<void>;
11
+ stopped(suspension: Suspension): Promise<boolean>;
12
+ alive?(suspension: Suspension): Promise<boolean>;
13
+ owned?(suspension: Suspension): Promise<boolean>;
14
+ daemonIdentity?(): Promise<string>;
15
+ }
16
+
17
+ /** Linux process suspension keeps native workers and their in-memory turns intact.
18
+ * Roots must be direct children of this daemon with its exact injected agent ID.
19
+ * No process-name scans, credentials, transcripts, or provider sessions are used.
20
+ */
21
+ export class LinuxProcessControl implements ProcessControl {
22
+ private readonly daemonPid: number;
23
+ constructor(daemonPid: number = process.ppid) { this.daemonPid = daemonPid; }
24
+ private async inspect(pid: number): Promise<Snapshot | null> {
25
+ try {
26
+ const raw = await readFile(`/proc/${pid}/stat`, "utf8");
27
+ const fields = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/);
28
+ if (!fields[19] || !Number.isInteger(Number(fields[1]))) return null;
29
+ return { pid, state: fields[0], ppid: Number(fields[1]), startTime: fields[19] };
30
+ } catch (error) {
31
+ if (["ENOENT", "ESRCH"].includes((error as NodeJS.ErrnoException).code ?? "")) return null;
32
+ throw new Error("Provider process could not be inspected.");
33
+ }
34
+ }
35
+ private async table(): Promise<Snapshot[]> {
36
+ const entries = await readdir("/proc");
37
+ const result: Snapshot[] = [];
38
+ // Bounded batches avoid opening one descriptor per host process at once.
39
+ const pids = entries.filter(name => /^\d+$/.test(name)).map(Number);
40
+ for (let i = 0; i < pids.length; i += 64) {
41
+ const batch = await Promise.all(pids.slice(i, i + 64).map(pid => this.inspect(pid)));
42
+ result.push(...batch.filter((item): item is Snapshot => item !== null));
43
+ }
44
+ return result;
45
+ }
46
+ async discover(agent: Agent, daemonPid: number): Promise<Suspension | null> {
47
+ if (process.platform !== "linux" || !["claude", "codex"].includes(agent.provider)) return null;
48
+ if (!Number.isInteger(daemonPid) || daemonPid <= 1 || daemonPid !== this.daemonPid || daemonPid === process.pid) return null;
49
+ const candidates: Snapshot[] = [];
50
+ for (const child of (await this.table()).filter(p => p.ppid === daemonPid)) {
51
+ if (child.pid === process.pid || ["T", "t", "Z", "X"].includes(child.state)) continue;
52
+ try {
53
+ const environment = await readFile(`/proc/${child.pid}/environ`);
54
+ const owner = environment.toString().split("\0").find(field => field.startsWith("PASEO_AGENT_ID="));
55
+ if (owner === `PASEO_AGENT_ID=${agent.id}`) candidates.push(child);
56
+ } catch (error) {
57
+ if (!["ENOENT", "ESRCH", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
58
+ }
59
+ }
60
+ // Ambiguous ownership must never freeze a shared process or another session.
61
+ if (candidates.length !== 1) return null;
62
+ const root = candidates[0];
63
+ return { rootPid: root.pid, processes: [{ pid: root.pid, startTime: root.startTime }] };
64
+ }
65
+ private async signal(identity: Identity, signal: "SIGSTOP" | "SIGCONT"): Promise<boolean> {
66
+ if (identity.pid <= 1 || identity.pid === process.pid || identity.pid === process.ppid) throw new Error("Unsafe provider process identity.");
67
+ const snapshot = await this.inspect(identity.pid);
68
+ if (!snapshot || snapshot.startTime !== identity.startTime || ["Z", "X"].includes(snapshot.state)) return false;
69
+ try { process.kill(identity.pid, signal); return true; }
70
+ catch (error) {
71
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
72
+ throw new Error("Provider process signal failed.");
73
+ }
74
+ }
75
+ async freeze(suspension: Suspension, journal: () => Promise<void>): Promise<void> {
76
+ const root = suspension.processes.find(p => p.pid === suspension.rootPid);
77
+ if (!root || !await this.signal(root, "SIGSTOP")) throw new Error("Provider process exited before suspension.");
78
+ // Stop the root before traversing. Descendants are then stopped as discovered;
79
+ // repeat to catch a tool worker that forked during the previous scan.
80
+ const seen = new Set(suspension.processes.map(p => `${p.pid}:${p.startTime}`));
81
+ for (let pass = 0; pass < 8; pass++) {
82
+ const table = await this.table();
83
+ const owned = new Set<number>();
84
+ for (const p of suspension.processes) {
85
+ if (table.some(s => s.pid === p.pid && s.startTime === p.startTime)) owned.add(p.pid);
86
+ }
87
+ let grew = true;
88
+ while (grew) {
89
+ grew = false;
90
+ for (const p of table) if (owned.has(p.ppid) && !owned.has(p.pid)) { owned.add(p.pid); grew = true; }
91
+ }
92
+ const additions = table.filter(p => owned.has(p.pid) && !seen.has(`${p.pid}:${p.startTime}`) && !["Z", "X"].includes(p.state));
93
+ if (!additions.length) {
94
+ if (!await this.stopped(suspension)) throw new Error("Provider suspension was externally changed.");
95
+ return;
96
+ }
97
+ for (const p of additions) {
98
+ seen.add(`${p.pid}:${p.startTime}`);
99
+ // A worker already stopped by a person/debugger is never ours to resume.
100
+ if (["T", "t"].includes(p.state)) continue;
101
+ suspension.processes.push({ pid: p.pid, startTime: p.startTime });
102
+ await journal(); // Durable identity before each signal, including crash windows.
103
+ await this.signal(p, "SIGSTOP");
104
+ }
105
+ }
106
+ throw new Error("Provider process tree did not settle.");
107
+ }
108
+ async resume(suspension: Suspension): Promise<void> {
109
+ let failed = false;
110
+ // Tools first, then root, so the provider can consume their pending responses.
111
+ for (const p of [...suspension.processes].reverse()) {
112
+ try { await this.signal(p, "SIGCONT"); } catch { failed = true; }
113
+ }
114
+ if (failed) throw new Error("Some suspended provider processes could not be released.");
115
+ }
116
+ async stopped(suspension: Suspension): Promise<boolean> {
117
+ const root = suspension.processes.find(p => p.pid === suspension.rootPid);
118
+ if (!root) return false;
119
+ const snapshot = await this.inspect(root.pid);
120
+ if (!snapshot || snapshot.startTime !== root.startTime || !["T", "t"].includes(snapshot.state)) return false;
121
+ for (const p of suspension.processes) {
122
+ if (p.pid === root.pid) continue;
123
+ const child = await this.inspect(p.pid);
124
+ if (child && child.startTime === p.startTime && !["T", "t", "Z", "X"].includes(child.state)) return false;
125
+ }
126
+ return true;
127
+ }
128
+ async daemonIdentity(): Promise<string> {
129
+ if (process.platform !== "linux") return `portable:${this.daemonPid}`;
130
+ const boot = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
131
+ const daemon = await this.inspect(this.daemonPid);
132
+ if (!daemon) throw new Error("Daemon identity is unavailable.");
133
+ return `${boot}:${daemon.pid}:${daemon.startTime}`;
134
+ }
135
+ async owned(suspension: Suspension): Promise<boolean> {
136
+ const root = suspension.processes.find(p => p.pid === suspension.rootPid);
137
+ if (!root) return false;
138
+ const snapshot = await this.inspect(root.pid);
139
+ return !!snapshot && snapshot.startTime === root.startTime && snapshot.ppid === this.daemonPid;
140
+ }
141
+ async alive(suspension: Suspension): Promise<boolean> {
142
+ const root = suspension.processes.find(p => p.pid === suspension.rootPid);
143
+ if (!root) return false;
144
+ const snapshot = await this.inspect(root.pid);
145
+ return !!snapshot && snapshot.startTime === root.startTime && !["Z", "X"].includes(snapshot.state);
146
+ }
147
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Recognising the standalone `paseo-session-guard` plugin among the daemon's plugins.
3
+ *
4
+ * Sharing its lock keeps the two plugins from pausing a session twice, but it does not
5
+ * cover the standalone plugin's teardown: released versions call `engine.stop()` on
6
+ * disable or uninstall whether or not they hold the lock, which resumes every
7
+ * suspended session in `state.json` and rewrites the file. Those versions cannot be
8
+ * changed, so Smart Session refuses to govern at all while the standalone plugin is
9
+ * configured and enabled. A disabled one has already run that teardown and is safe.
10
+ */
11
+
12
+ import { readFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+
15
+ export const STANDALONE_ID = "paseo-session-guard";
16
+
17
+ /** The fields of the daemon's plugin listing this reads; older daemons omit some. */
18
+ export interface ListedPlugin {
19
+ readonly id: string;
20
+ readonly path?: string;
21
+ readonly enabled: boolean;
22
+ readonly remote?: string;
23
+ readonly npm?: { readonly packageName?: string };
24
+ }
25
+
26
+ type ReadText = (path: string) => Promise<string>;
27
+ const readText: ReadText = (path) => readFile(path, "utf8");
28
+
29
+ async function readJson(read: ReadText, path: string): Promise<Record<string, unknown> | null> {
30
+ try {
31
+ const value = JSON.parse(await read(path)) as unknown;
32
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Whether one listed plugin is the standalone Session Guard.
40
+ *
41
+ * The install id can be anything (`--id`), so the manifest and package name in its
42
+ * directory are checked too, along with the npm package and Git remote the daemon
43
+ * recorded.
44
+ */
45
+ export async function isStandalone(plugin: ListedPlugin, read: ReadText = readText): Promise<boolean> {
46
+ if (plugin.id === STANDALONE_ID || plugin.npm?.packageName === STANDALONE_ID) return true;
47
+ if (plugin.remote !== undefined && /[/:]paseo-session-guard(\.git)?\/?$/i.test(plugin.remote)) return true;
48
+ if (!plugin.path) return false;
49
+ const manifest = await readJson(read, join(plugin.path, "paseo-plugin.json"));
50
+ if (manifest?.id === STANDALONE_ID) return true;
51
+ const pkg = await readJson(read, join(plugin.path, "package.json"));
52
+ return pkg?.name === STANDALONE_ID;
53
+ }
54
+
55
+ /** Whether any configured, enabled plugin is the standalone Session Guard. */
56
+ export async function standaloneEnabled(plugins: readonly ListedPlugin[], read: ReadText = readText): Promise<boolean> {
57
+ for (const plugin of plugins) {
58
+ if (plugin.enabled && (await isStandalone(plugin, read))) return true;
59
+ }
60
+ return false;
61
+ }