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,554 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Connect, GuardClient } from "./client.ts";
|
|
3
|
+
import type { Store } from "./store.ts";
|
|
4
|
+
import type { ProcessControl } from "./processes.ts";
|
|
5
|
+
import type { PublishTimeline } from "./timeline.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How one tick reads provider usage. Defaults to Paseo's own report; the plugin
|
|
9
|
+
* passes one that also consults Smart Session's recorder (see `./usage.ts`).
|
|
10
|
+
*/
|
|
11
|
+
export type ReadUsage = (client: GuardClient) => Promise<Usage>;
|
|
12
|
+
import {
|
|
13
|
+
isPending, protects, resetReady, sessionWindow, timestamp, usedPct,
|
|
14
|
+
type Agent, type Pause, type Policy, type State, type Usage,
|
|
15
|
+
} from "../../shared/guard/policy.ts";
|
|
16
|
+
|
|
17
|
+
export class Engine {
|
|
18
|
+
private chain: Promise<unknown> = Promise.resolve();
|
|
19
|
+
private stopped = false;
|
|
20
|
+
private nextResumeAt = 0;
|
|
21
|
+
private quotaFailures = new Map<string, { at: string; turnId: string | null }>();
|
|
22
|
+
checkedAt: string | null = null;
|
|
23
|
+
error: string | null = null;
|
|
24
|
+
timelineError: string | null = null;
|
|
25
|
+
usage: Usage | null = null;
|
|
26
|
+
private store: Store;
|
|
27
|
+
private policy: () => Promise<Policy>;
|
|
28
|
+
private connect: Connect;
|
|
29
|
+
private now: () => number;
|
|
30
|
+
private processes?: ProcessControl;
|
|
31
|
+
private publish?: PublishTimeline;
|
|
32
|
+
private publishing: Promise<void> | null = null;
|
|
33
|
+
private readUsage: ReadUsage;
|
|
34
|
+
constructor(store: Store, policy: () => Promise<Policy>, connect: Connect, now: () => number = Date.now, processes?: ProcessControl, publish?: PublishTimeline,
|
|
35
|
+
readUsage: ReadUsage = client => client.listProviderUsage()) {
|
|
36
|
+
this.store = store; this.policy = policy; this.connect = connect; this.now = now;
|
|
37
|
+
this.processes = processes;
|
|
38
|
+
this.publish = publish;
|
|
39
|
+
this.readUsage = readUsage;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async save(state: State) {
|
|
43
|
+
// The quota action is durable first. A timeline failure must never undo it
|
|
44
|
+
// or stop other sessions from being protected.
|
|
45
|
+
await this.store.write(state);
|
|
46
|
+
if (!this.publish) return;
|
|
47
|
+
try {
|
|
48
|
+
const policy = await this.policy();
|
|
49
|
+
for (const pause of state.pauses) {
|
|
50
|
+
const event = pause.phase === "waiting" ? pause.restartRecovery ? "recovery" : "paused" : pause.phase === "resumed" ? "resumed"
|
|
51
|
+
: pause.phase === "cancelled" ? "released" : pause.phase === "attention" ? "attention" : null;
|
|
52
|
+
if (!event || (["released", "resumed"].includes(event) && !pause.notifications?.length)) continue;
|
|
53
|
+
pause.notifications ??= [];
|
|
54
|
+
if (pause.notifications.some(n => n.data.event === event)) continue;
|
|
55
|
+
const native = event === "resumed" ? !!pause.resumedInPlace : event === "recovery" ? false
|
|
56
|
+
: !!pause.suspension || !!pause.resumedInPlace || pause.notifications.some(n => n.data.native);
|
|
57
|
+
const window = this.usage?.providers.find(p => p.providerId === pause.provider)?.windows.find(w => w.id === pause.windowId);
|
|
58
|
+
pause.notifications.push({ id: `session-guard:${pause.id}:${event}`, delivery: "pending", data: {
|
|
59
|
+
pauseId: pause.id, event, at: event === "paused" ? pause.pausedAt : pause.settledAt ?? new Date(this.now()).toISOString(),
|
|
60
|
+
provider: pause.provider, native, autoResume: !pause.demo && policy.autoResume && policy.enabled,
|
|
61
|
+
resetAt: pause.anchorResetsAt, usedPct: window ? usedPct(window) : null, note: pause.note,
|
|
62
|
+
...(pause.manualResume ? { manualResume: true } : {}), ...(pause.demo ? { demo: true } : {}), ...(pause.dismissed ? { dismissed: true } : {}),
|
|
63
|
+
} });
|
|
64
|
+
}
|
|
65
|
+
await this.store.write(state);
|
|
66
|
+
} catch {
|
|
67
|
+
this.timelineError = "Session Guard could not update the timeline yet. Pauses remain protected; delivery will retry.";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Timeline I/O runs outside quota actions, so a slow append cannot prevent
|
|
72
|
+
* another session from pausing or block the person's release action. */
|
|
73
|
+
deliverTimeline(): Promise<void> {
|
|
74
|
+
if (!this.publish || this.stopped) return Promise.resolve();
|
|
75
|
+
if (this.publishing) return this.publishing;
|
|
76
|
+
this.publishing = (async () => {
|
|
77
|
+
const failed = new Set<string>();
|
|
78
|
+
try {
|
|
79
|
+
for (let batch = 0; batch < 20 && !this.stopped; batch++) {
|
|
80
|
+
const next = await this.exclusive(async () => {
|
|
81
|
+
const state = await this.store.read();
|
|
82
|
+
for (const pause of state.pauses) {
|
|
83
|
+
if (failed.has(pause.id)) continue;
|
|
84
|
+
const notification = pause.notifications?.find(n => n.delivery !== "sent");
|
|
85
|
+
if (!notification) continue;
|
|
86
|
+
const reconcile = notification.delivery === "sending";
|
|
87
|
+
notification.delivery = "sending"; await this.store.write(state);
|
|
88
|
+
return { agentId: pause.agentId, notification: structuredClone(notification), reconcile };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
});
|
|
92
|
+
if (!next) { if (!failed.size) this.timelineError = null; return; }
|
|
93
|
+
try { await this.publish!(next.agentId, next.notification, next.reconcile); }
|
|
94
|
+
catch {
|
|
95
|
+
this.timelineError = "Some Session Guard timeline entries are awaiting delivery. Pauses remain protected; delivery will retry.";
|
|
96
|
+
failed.add(next.notification.data.pauseId);
|
|
97
|
+
continue; // Keep this pause's events in order, while delivering other sessions.
|
|
98
|
+
}
|
|
99
|
+
await this.exclusive(async () => {
|
|
100
|
+
const state = await this.store.read();
|
|
101
|
+
const notification = state.pauses.flatMap(p => p.notifications ?? []).find(n => n.id === next.notification.id);
|
|
102
|
+
if (notification) { notification.delivery = "sent"; await this.store.write(state); }
|
|
103
|
+
});
|
|
104
|
+
if (!failed.size) this.timelineError = null;
|
|
105
|
+
}
|
|
106
|
+
} catch { this.timelineError = "Session Guard could not update the timeline yet. Delivery will retry."; }
|
|
107
|
+
})().finally(() => { this.publishing = null; });
|
|
108
|
+
return this.publishing;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Serialize whole actions, so an opt-out cannot race a scheduled send. */
|
|
112
|
+
exclusive<T>(action: () => Promise<T>): Promise<T> {
|
|
113
|
+
const next = this.chain.then(action);
|
|
114
|
+
this.chain = next.catch(() => undefined);
|
|
115
|
+
return next;
|
|
116
|
+
}
|
|
117
|
+
async stop() {
|
|
118
|
+
this.stopped = true; await this.chain;
|
|
119
|
+
// Disabling/unloading a plugin must not strand stopped OS processes.
|
|
120
|
+
try { await this.releaseWorkers(); }
|
|
121
|
+
finally { await this.publishing; }
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Releases every suspended worker, keeping confirmed waits on disk.
|
|
125
|
+
*
|
|
126
|
+
* Used on unload and when handing the host to another governor. Each pause is
|
|
127
|
+
* released on its own: one failed SIGCONT must not leave the sessions after it
|
|
128
|
+
* frozen, nor lose what was already released. Failures are rethrown together once
|
|
129
|
+
* the state is saved.
|
|
130
|
+
*/
|
|
131
|
+
releaseWorkers(): Promise<void> {
|
|
132
|
+
if (!this.processes) return Promise.resolve();
|
|
133
|
+
return this.exclusive(async () => {
|
|
134
|
+
const state = await this.store.read();
|
|
135
|
+
let failed = 0;
|
|
136
|
+
for (const pause of state.pauses.filter(p => isPending(p) && p.suspension)) {
|
|
137
|
+
try {
|
|
138
|
+
if (["waiting", "resuming"].includes(pause.phase) && !pause.demo && !pause.manualResume) {
|
|
139
|
+
// Unload must release OS workers, but the confirmed schedule remains
|
|
140
|
+
// durable in case the daemon now exits and those workers disappear.
|
|
141
|
+
await this.processes!.resume(pause.suspension!);
|
|
142
|
+
pause.releasedForUnload = true;
|
|
143
|
+
} else await this.cancel(pause, "Suspended workers released because Session Guard stopped.");
|
|
144
|
+
} catch { failed++; }
|
|
145
|
+
}
|
|
146
|
+
await this.save(state);
|
|
147
|
+
if (failed) throw new Error(`${failed} suspended ${failed === 1 ? "session" : "sessions"} could not be released.`);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
quotaFailure(agentId: string, message: string, turnId: string | null = null) {
|
|
151
|
+
// Provider rejection can win the race against the usage polling interval.
|
|
152
|
+
// Only rate/quota failures qualify; ordinary errors must never restart work.
|
|
153
|
+
if (/usage limit|rate.?limit|hit your limit|quota|limit reached|exceeded.*limit|limit.*exceeded|too many requests/i.test(message)) {
|
|
154
|
+
this.quotaFailures.set(agentId, { at: new Date(this.now()).toISOString(), turnId });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
private async leftDaemon(suspension: NonNullable<Pause["suspension"]>) {
|
|
158
|
+
return !!this.processes && ((this.processes.alive && !await this.processes.alive(suspension)) ||
|
|
159
|
+
(this.processes.owned && !await this.processes.owned(suspension))) === true;
|
|
160
|
+
}
|
|
161
|
+
private async recoverActions(state: Awaited<ReturnType<Store["read"]>>) {
|
|
162
|
+
let changed = false;
|
|
163
|
+
for (const pause of state.pauses) {
|
|
164
|
+
if (pause.phase === "resuming" || pause.phase === "pausing") {
|
|
165
|
+
if (pause.suspension && this.processes) {
|
|
166
|
+
const workerEnded = await this.leftDaemon(pause.suspension);
|
|
167
|
+
await this.processes.resume(pause.suspension);
|
|
168
|
+
delete pause.suspension;
|
|
169
|
+
if (pause.phase === "resuming" && workerEnded) {
|
|
170
|
+
// A crash after journaling SIGCONT must not mark a dead worker as
|
|
171
|
+
// resumed and drop the original confirmed wait.
|
|
172
|
+
pause.phase = pause.manualResume || pause.demo ? "attention" : "waiting";
|
|
173
|
+
pause.restartRecovery = pause.phase === "waiting";
|
|
174
|
+
pause.settledAt = null;
|
|
175
|
+
pause.note = pause.phase === "waiting" ? "Paseo's original workers ended during release. This conversation will continue after the quota resets."
|
|
176
|
+
: "The worker ended during manual release. Check the session before continuing.";
|
|
177
|
+
changed = true; continue;
|
|
178
|
+
}
|
|
179
|
+
if (pause.phase === "resuming") {
|
|
180
|
+
pause.phase = pause.wasQuotaFailure && !pause.manualResume ? "waiting" : "resumed";
|
|
181
|
+
pause.resumedInPlace = pause.phase === "resumed";
|
|
182
|
+
pause.settledAt = pause.phase === "waiting" ? null : new Date(this.now()).toISOString(); pause.note = null;
|
|
183
|
+
changed = true; continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
pause.phase = "attention";
|
|
187
|
+
pause.note = "A reload interrupted an action before it was confirmed. Check the session before continuing manually.";
|
|
188
|
+
changed = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (changed) await this.save(state);
|
|
192
|
+
}
|
|
193
|
+
async recover() {
|
|
194
|
+
return this.exclusive(async () => {
|
|
195
|
+
await this.recoverActions(await this.store.read());
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
async setProtection(agentId: string, enabled: boolean | null) {
|
|
199
|
+
return this.exclusive(async () => {
|
|
200
|
+
const state = await this.store.read();
|
|
201
|
+
if (enabled === null) delete state.overrides[agentId];
|
|
202
|
+
else state.overrides[agentId] = enabled;
|
|
203
|
+
const policy = await this.policy();
|
|
204
|
+
if (!protects(policy, state.overrides, agentId)) {
|
|
205
|
+
for (const pause of state.pauses.filter(p => p.agentId === agentId && isPending(p))) await this.cancel(pause, "Protection turned off for this session.");
|
|
206
|
+
}
|
|
207
|
+
await this.save(state);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
async cancelResume(agentId: string) {
|
|
211
|
+
return this.exclusive(async () => {
|
|
212
|
+
const state = await this.store.read();
|
|
213
|
+
for (const pause of state.pauses.filter(p => p.agentId === agentId && isPending(p))) {
|
|
214
|
+
// Releasing live workers opts out so the next poll does not freeze them again.
|
|
215
|
+
if (pause.suspension) state.overrides[agentId] = false;
|
|
216
|
+
await this.cancel(pause, pause.suspension ? "Workers resumed immediately and this session was excluded from Session Guard."
|
|
217
|
+
: "Automatic continuation cancelled. Continue the session manually when ready.");
|
|
218
|
+
}
|
|
219
|
+
await this.save(state);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/** Dismiss this exact pause without creating a message or restarting a turn. */
|
|
223
|
+
async dismissPause(agentId: string, pauseId: string) {
|
|
224
|
+
return this.exclusive(async () => {
|
|
225
|
+
const state = await this.store.read();
|
|
226
|
+
const pause = state.pauses.find(p => p.id === pauseId && p.agentId === agentId);
|
|
227
|
+
if (pause?.phase === "cancelled" && pause.dismissed) return;
|
|
228
|
+
if (!pause || !(pause.phase === "waiting" || (pause.phase === "attention" && pause.dismissed))) throw new Error("This pause is no longer available to dismiss.");
|
|
229
|
+
state.overrides[agentId] = false;
|
|
230
|
+
pause.dismissed = true;
|
|
231
|
+
// Journal cancellation intent before SIGCONT: a crash or partial release
|
|
232
|
+
// must never revive its scheduled continuation on the next boot.
|
|
233
|
+
await this.store.write(state);
|
|
234
|
+
try {
|
|
235
|
+
await this.cancel(pause, "Pause dismissed without sending a message. Protection is off for this session.");
|
|
236
|
+
} catch {
|
|
237
|
+
pause.phase = "attention";
|
|
238
|
+
pause.note = "Pause dismissal could not be completed. Protection is off; worker release will retry without sending a message.";
|
|
239
|
+
await this.save(state);
|
|
240
|
+
throw new Error(pause.note);
|
|
241
|
+
}
|
|
242
|
+
await this.save(state);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/** Explicitly resumes this exact pause, even before the provider resets. */
|
|
246
|
+
async resumeNow(agentId: string, pauseId: string) {
|
|
247
|
+
return this.exclusive(async () => {
|
|
248
|
+
const state = await this.store.read();
|
|
249
|
+
const pause = state.pauses.find(p => p.id === pauseId && p.agentId === agentId);
|
|
250
|
+
if (!pause || !isPending(pause)) return;
|
|
251
|
+
if (pause.phase !== "waiting") throw new Error("Check this session before resuming it.");
|
|
252
|
+
await this.connect(async client => {
|
|
253
|
+
const latest = (await client.fetchAgent(agentId))?.agent;
|
|
254
|
+
if (!latest || latest.archivedAt || (latest.status === "closed" && !pause.closedOnRestart) || latest.provider !== pause.provider ||
|
|
255
|
+
(latest.lastUserMessageAt ?? null) !== pause.lastUserMessageAt ||
|
|
256
|
+
(pause.suspension ? (latest.activeTurn?.turnId ?? null) !== pause.turnId
|
|
257
|
+
: !(latest.status === "idle" || (pause.closedOnRestart && latest.status === "closed") || (pause.wasQuotaFailure && latest.status === "error")) || !!latest.pendingPermissions?.length)) {
|
|
258
|
+
throw new Error("The session changed. Check it before continuing.");
|
|
259
|
+
}
|
|
260
|
+
if (pause.suspension && !this.processes) throw new Error("Native worker protection is unavailable.");
|
|
261
|
+
state.overrides[agentId] = false; pause.manualResume = true; pause.phase = "resuming";
|
|
262
|
+
await this.save(state);
|
|
263
|
+
const needsMessage = !pause.suspension || (pause.wasQuotaFailure && ["idle", "error"].includes(latest.status) && !latest.pendingPermissions?.length);
|
|
264
|
+
if (pause.suspension) {
|
|
265
|
+
// Identity-safe SIGCONT can be retried after a partial release.
|
|
266
|
+
await this.processes!.resume(pause.suspension);
|
|
267
|
+
delete pause.suspension; pause.resumedInPlace = true;
|
|
268
|
+
}
|
|
269
|
+
if (needsMessage) {
|
|
270
|
+
try {
|
|
271
|
+
await client.sendMessage(agentId, "You explicitly chose Resume now while Session Guard was waiting for the provider's session quota to reset. Continue the unfinished task in this conversation. Inspect interrupted operations before retrying. The provider may still reject requests until the quota resets. If the task is already complete, do not repeat it.", { messageId: pause.id, activeTurnBehavior: "steer" });
|
|
272
|
+
} catch {
|
|
273
|
+
pause.phase = "attention";
|
|
274
|
+
pause.note = "Manual continuation could not be confirmed. Check the session before sending another message.";
|
|
275
|
+
await this.save(state);
|
|
276
|
+
throw new Error(pause.note);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
pause.phase = "resumed"; pause.settledAt = new Date(this.now()).toISOString();
|
|
280
|
+
pause.note = "Resumed manually; protection is off for this session.";
|
|
281
|
+
await this.save(state);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/** Only an explicitly labelled, empty disposable demo session may use this. */
|
|
286
|
+
async pauseDemo(agentId: string) {
|
|
287
|
+
return this.exclusive(async () => {
|
|
288
|
+
const state = await this.store.read();
|
|
289
|
+
if (state.pauses.some(p => p.agentId === agentId && isPending(p))) return;
|
|
290
|
+
await this.connect(async client => {
|
|
291
|
+
const agent = (await client.fetchAgent(agentId))?.agent;
|
|
292
|
+
if (!agent || agent.labels?.session_guard_demo !== "true" || agent.status !== "idle" || agent.archivedAt ||
|
|
293
|
+
agent.lastUserMessageAt || agent.activeTurn || agent.pendingPermissions?.length || !this.processes || !client.getDaemonStatus) {
|
|
294
|
+
throw new Error("Only an empty Session Guard demo session can be paused for testing.");
|
|
295
|
+
}
|
|
296
|
+
const suspension = await this.processes.discover(agent, (await client.getDaemonStatus()).pid);
|
|
297
|
+
if (!suspension) throw new Error("The demo provider process is unavailable.");
|
|
298
|
+
const pause: Pause = { id: randomUUID(), agentId, workspaceId: agent.workspaceId, provider: agent.provider, title: agent.title ?? "Session Guard demo",
|
|
299
|
+
windowId: "demo", anchorResetsAt: null, pausedAt: new Date(this.now()).toISOString(),
|
|
300
|
+
lastUserMessageAt: null, turnId: null, phase: "pausing", note: "Test pause · waiting for your Resume now action.",
|
|
301
|
+
settledAt: null, wasQuotaFailure: false, demo: true, suspension, daemonIdentity: await this.processes.daemonIdentity?.() };
|
|
302
|
+
state.overrides[agentId] = true; state.pauses.push(pause); await this.save(state);
|
|
303
|
+
try { await this.processes.freeze(suspension, () => this.store.write(state)); pause.phase = "waiting"; }
|
|
304
|
+
catch { await this.processes.resume(suspension); delete pause.suspension; pause.phase = "attention"; pause.note = "The demo pause could not be confirmed."; }
|
|
305
|
+
await this.save(state);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
private async cancel(pause: Pause, note: string) {
|
|
310
|
+
if (pause.suspension) {
|
|
311
|
+
if (!this.processes) throw new Error("Suspended workers require a process controller.");
|
|
312
|
+
await this.processes.resume(pause.suspension);
|
|
313
|
+
delete pause.suspension;
|
|
314
|
+
}
|
|
315
|
+
pause.phase = "cancelled"; pause.note = note; pause.settledAt = new Date(this.now()).toISOString();
|
|
316
|
+
}
|
|
317
|
+
/** A normally completed turn racing cancel must never become a continuation. */
|
|
318
|
+
async turnEnded(agentId: string, turnId: string | null, outcome: string) {
|
|
319
|
+
return this.exclusive(async () => {
|
|
320
|
+
if (!["completed", "canceled"].includes(outcome)) return;
|
|
321
|
+
const state = await this.store.read();
|
|
322
|
+
let changed = false;
|
|
323
|
+
for (const pause of state.pauses.filter(p => p.agentId === agentId && isPending(p))) {
|
|
324
|
+
if (pause.suspension) {
|
|
325
|
+
if (outcome === "canceled" && turnId === pause.turnId) {
|
|
326
|
+
pause.stoppedByUser = true;
|
|
327
|
+
await this.cancel(pause, "The suspended turn was stopped by the user."); changed = true;
|
|
328
|
+
}
|
|
329
|
+
continue; // A completed foreground can still own background workers.
|
|
330
|
+
}
|
|
331
|
+
if (outcome !== "completed") continue;
|
|
332
|
+
if (turnId !== null && pause.turnId === turnId) {
|
|
333
|
+
await this.cancel(pause, "The interrupted turn completed before the pause."); changed = true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (changed) await this.save(state);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
tick(): Promise<void> {
|
|
340
|
+
return this.exclusive(async () => {
|
|
341
|
+
if (this.stopped) return;
|
|
342
|
+
try { this.error = null; await this.flush(); }
|
|
343
|
+
catch { this.error = "Session Guard could not check the daemon or saved state. It will retry automatically."; }
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
private async flush() {
|
|
347
|
+
const policy = await this.policy();
|
|
348
|
+
if (this.stopped) return;
|
|
349
|
+
const state = await this.store.read();
|
|
350
|
+
// Also backfill confirmed pauses created before timeline support was installed.
|
|
351
|
+
if (this.publish) await this.save(state);
|
|
352
|
+
// Process release is safely retryable after a partial signal failure.
|
|
353
|
+
if (state.pauses.some(p => p.suspension && ["pausing", "resuming"].includes(p.phase))) await this.recoverActions(state);
|
|
354
|
+
// Legacy continuations can wait while disabled; native OS workers must be
|
|
355
|
+
// released when their protection is disabled, even if the daemon is offline.
|
|
356
|
+
let released = false;
|
|
357
|
+
for (const pause of state.pauses.filter(p => isPending(p) && p.suspension)) {
|
|
358
|
+
if (!policy.enabled || !policy.includeNativeSubagents || !protects(policy, state.overrides, pause.agentId) ||
|
|
359
|
+
(policy.providerIds.length > 0 && !policy.providerIds.includes(pause.provider))) {
|
|
360
|
+
await this.cancel(pause, "Native worker protection turned off; workers released."); released = true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (released) await this.save(state);
|
|
364
|
+
if (!policy.enabled) return;
|
|
365
|
+
await this.connect(async client => {
|
|
366
|
+
const agents: Agent[] = [];
|
|
367
|
+
let cursor: string | undefined;
|
|
368
|
+
do {
|
|
369
|
+
const page = await client.fetchAgents({ page: { limit: 200, ...(cursor ? { cursor } : {}) } });
|
|
370
|
+
agents.push(...page.entries.map(e => e.agent));
|
|
371
|
+
if (!page.pageInfo?.hasMore) break;
|
|
372
|
+
const next = page.pageInfo.nextCursor;
|
|
373
|
+
if (!next || next === cursor) throw new Error("Agent pagination did not advance.");
|
|
374
|
+
cursor = next;
|
|
375
|
+
} while (!this.stopped);
|
|
376
|
+
if (this.stopped) return;
|
|
377
|
+
// New usage is always read through the daemon, never provider credentials.
|
|
378
|
+
const usage = await this.readUsage(client);
|
|
379
|
+
this.usage = usage;
|
|
380
|
+
this.checkedAt = new Date(this.now()).toISOString();
|
|
381
|
+
const byId = new Map(agents.map(a => [a.id, a]));
|
|
382
|
+
const currentDaemon = await this.processes?.daemonIdentity?.();
|
|
383
|
+
let daemonPid: number | null = null;
|
|
384
|
+
const nativeRunning = async (id: string) => client.listProviderSubagents
|
|
385
|
+
? (await client.listProviderSubagents(id)).subagents.some(s => s.status === "running") : false;
|
|
386
|
+
let changed = false;
|
|
387
|
+
// A quota response already in flight at SIGSTOP can arrive after SIGCONT.
|
|
388
|
+
// Recover only that same failed native turn, once, using its original
|
|
389
|
+
// reset anchor; unrelated errors and newer user work remain untouched.
|
|
390
|
+
for (const pause of state.pauses.filter(p => p.phase === "resumed" && p.resumedInPlace && !p.wasQuotaFailure && !p.manualResume)) {
|
|
391
|
+
const agent = byId.get(pause.agentId);
|
|
392
|
+
const failure = this.quotaFailures.get(pause.agentId);
|
|
393
|
+
if (agent && ["idle", "error"].includes(agent.status) && failure && failure.turnId === pause.turnId &&
|
|
394
|
+
timestamp(failure.at)! >= timestamp(pause.settledAt)! && (agent.lastUserMessageAt ?? null) === pause.lastUserMessageAt) {
|
|
395
|
+
pause.wasQuotaFailure = true; pause.phase = "waiting"; pause.settledAt = null; changed = true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const pause of state.pauses.filter(isPending)) {
|
|
399
|
+
// Ambiguous manual sends remain for inspection, never a blind retry.
|
|
400
|
+
if (pause.manualResume && pause.phase === "attention") continue;
|
|
401
|
+
const agent = byId.get(pause.agentId);
|
|
402
|
+
const restarted = !!currentDaemon && !!pause.daemonIdentity && currentDaemon !== pause.daemonIdentity;
|
|
403
|
+
if (restarted && agent?.status === "closed" && !agent.archivedAt && !pause.demo && !pause.stoppedByUser &&
|
|
404
|
+
!pause.manualResume && agent.provider === pause.provider && (agent.lastUserMessageAt ?? null) === pause.lastUserMessageAt &&
|
|
405
|
+
protects(policy, state.overrides, pause.agentId)) {
|
|
406
|
+
// Graceful shutdown persists closed snapshots. Only a changed daemon
|
|
407
|
+
// identity permits reopening one; a user close in this daemon cancels.
|
|
408
|
+
if (pause.suspension) { await this.processes!.resume(pause.suspension); delete pause.suspension; }
|
|
409
|
+
pause.closedOnRestart = true; pause.restartRecovery = true; pause.phase = "waiting"; pause.settledAt = null;
|
|
410
|
+
pause.note = "Paseo closed its workers during restart. This conversation will reopen after the quota resets.";
|
|
411
|
+
}
|
|
412
|
+
if (currentDaemon && pause.daemonIdentity !== currentDaemon) { pause.daemonIdentity = currentDaemon; changed = true; }
|
|
413
|
+
if (!agent || (agent.status === "closed" && !pause.closedOnRestart) || agent.archivedAt || agent.provider !== pause.provider ||
|
|
414
|
+
(agent.lastUserMessageAt ?? null) !== pause.lastUserMessageAt ||
|
|
415
|
+
!protects(policy, state.overrides, pause.agentId) ||
|
|
416
|
+
(policy.providerIds.length > 0 && !policy.providerIds.includes(pause.provider))) {
|
|
417
|
+
await this.cancel(pause, "Session changed, was closed, or protection was turned off."); changed = true; continue;
|
|
418
|
+
}
|
|
419
|
+
if (pause.phase !== "waiting") continue;
|
|
420
|
+
if (pause.suspension) {
|
|
421
|
+
if (!this.processes) throw new Error("Native worker protection is unavailable.");
|
|
422
|
+
const workerEnded = await this.leftDaemon(pause.suspension);
|
|
423
|
+
if (workerEnded || !await this.processes.stopped(pause.suspension)) {
|
|
424
|
+
if (!pause.demo && workerEnded && agent.status === "idle" && !pause.stoppedByUser) {
|
|
425
|
+
// The old PID is gone (or reused). Release only matching orphan
|
|
426
|
+
// identities, then keep the original reset anchor and conversation.
|
|
427
|
+
await this.processes.resume(pause.suspension);
|
|
428
|
+
delete pause.suspension; delete pause.releasedForUnload;
|
|
429
|
+
pause.restartRecovery = true;
|
|
430
|
+
pause.note = "Paseo's original workers ended. This conversation will continue after the quota resets.";
|
|
431
|
+
await this.save(state); continue;
|
|
432
|
+
}
|
|
433
|
+
await this.cancel(pause, "Provider workers exited or were released outside Session Guard."); changed = true; continue;
|
|
434
|
+
}
|
|
435
|
+
if ((agent.activeTurn?.turnId ?? null) !== pause.turnId && !await nativeRunning(agent.id)) {
|
|
436
|
+
await this.cancel(pause, "The suspended turn was stopped or changed outside Session Guard."); changed = true; continue;
|
|
437
|
+
}
|
|
438
|
+
if (pause.demo || !policy.autoResume || this.now() < this.nextResumeAt || !resetReady(pause, usage, policy, this.now())) continue;
|
|
439
|
+
const latest = (await client.fetchAgent(pause.agentId))?.agent;
|
|
440
|
+
if (!latest || latest.archivedAt || latest.status === "closed" || latest.provider !== pause.provider ||
|
|
441
|
+
(latest.lastUserMessageAt ?? null) !== pause.lastUserMessageAt ||
|
|
442
|
+
(latest.activeTurn?.turnId ?? null) !== (agent.activeTurn?.turnId ?? null)) continue;
|
|
443
|
+
if (this.stopped) return;
|
|
444
|
+
pause.phase = "resuming"; await this.save(state);
|
|
445
|
+
// SIGCONT is identity-checked and idempotent; it resumes the existing
|
|
446
|
+
// parent + nested workers without injecting a new turn/message.
|
|
447
|
+
await this.processes.resume(pause.suspension);
|
|
448
|
+
delete pause.suspension;
|
|
449
|
+
if (pause.wasQuotaFailure) {
|
|
450
|
+
pause.phase = "waiting"; await this.save(state); continue;
|
|
451
|
+
}
|
|
452
|
+
pause.phase = "resumed"; pause.settledAt = new Date(this.now()).toISOString(); pause.note = null;
|
|
453
|
+
pause.resumedInPlace = true;
|
|
454
|
+
this.nextResumeAt = this.now() + policy.resumeSpacingSeconds * 1000;
|
|
455
|
+
await this.save(state); continue;
|
|
456
|
+
}
|
|
457
|
+
if (agent.status === "running" && agent.activeTurn?.turnId !== pause.turnId) {
|
|
458
|
+
await this.cancel(pause, "Work was continued outside Session Guard."); changed = true; continue;
|
|
459
|
+
}
|
|
460
|
+
const canContinue = (candidate: Agent) => candidate.status === "idle" || (pause.closedOnRestart && candidate.status === "closed") || (pause.wasQuotaFailure && candidate.status === "error");
|
|
461
|
+
if (!canContinue(agent) || agent.pendingPermissions?.length || !policy.autoResume ||
|
|
462
|
+
this.now() < this.nextResumeAt || !resetReady(pause, usage, policy, this.now())) continue;
|
|
463
|
+
// Re-read just before sending: a person may have continued while usage was fetched.
|
|
464
|
+
const latest = (await client.fetchAgent(pause.agentId))?.agent;
|
|
465
|
+
if (!latest || !canContinue(latest) || latest.archivedAt || latest.pendingPermissions?.length ||
|
|
466
|
+
latest.provider !== pause.provider || (latest.lastUserMessageAt ?? null) !== pause.lastUserMessageAt) continue;
|
|
467
|
+
if (this.stopped) return;
|
|
468
|
+
pause.phase = "resuming";
|
|
469
|
+
await this.save(state);
|
|
470
|
+
try {
|
|
471
|
+
await client.sendMessage(pause.agentId, policy.continuation, { messageId: pause.id, activeTurnBehavior: "steer" });
|
|
472
|
+
pause.phase = "resumed"; pause.settledAt = new Date(this.now()).toISOString(); pause.note = null;
|
|
473
|
+
this.nextResumeAt = this.now() + policy.resumeSpacingSeconds * 1000;
|
|
474
|
+
} catch {
|
|
475
|
+
// Acceptance may have happened even if the socket dropped. No blind retry.
|
|
476
|
+
pause.phase = "attention";
|
|
477
|
+
pause.note = "Continuation could not be confirmed. Check the session and continue manually if needed.";
|
|
478
|
+
}
|
|
479
|
+
await this.save(state);
|
|
480
|
+
}
|
|
481
|
+
if (changed) await this.save(state);
|
|
482
|
+
for (const agent of agents) {
|
|
483
|
+
if (this.stopped) return;
|
|
484
|
+
if (state.pauses.some(p => p.agentId === agent.id && p.stoppedByUser &&
|
|
485
|
+
p.lastUserMessageAt === (agent.lastUserMessageAt ?? null) &&
|
|
486
|
+
(!agent.activeTurn || agent.activeTurn.turnId === p.turnId))) continue;
|
|
487
|
+
const failureAt = this.quotaFailures.get(agent.id)?.at;
|
|
488
|
+
if (failureAt && (timestamp(agent.lastUserMessageAt) ?? 0) > timestamp(failureAt)!) this.quotaFailures.delete(agent.id);
|
|
489
|
+
const quotaFailed = (agent.status === "error" || agent.status === "idle") && (
|
|
490
|
+
this.quotaFailures.has(agent.id) || (agent.lastError !== undefined && /usage limit|rate.?limit|hit your limit|quota|limit reached|exceeded.*limit|limit.*exceeded|too many requests/i.test(agent.lastError)));
|
|
491
|
+
if (quotaFailed && state.pauses.some(p => p.agentId === agent.id && p.wasQuotaFailure && p.lastUserMessageAt === (agent.lastUserMessageAt ?? null))) continue;
|
|
492
|
+
const nativeEnabled = !!this.processes && policy.includeNativeSubagents && ["claude", "codex"].includes(agent.provider);
|
|
493
|
+
if (agent.archivedAt || agent.status === "closed" || (!nativeEnabled && agent.pendingPermissions?.length) ||
|
|
494
|
+
agent.labels?.internal === "true" || !protects(policy, state.overrides, agent.id) ||
|
|
495
|
+
(policy.providerIds.length > 0 && !policy.providerIds.includes(agent.provider)) ||
|
|
496
|
+
state.pauses.some(p => p.agentId === agent.id && isPending(p))) continue;
|
|
497
|
+
const provider = usage.providers.find(p => p.providerId === agent.provider);
|
|
498
|
+
const window = sessionWindow(provider, policy);
|
|
499
|
+
if (!window || (usedPct(window) ?? -1) < policy.thresholdPct) continue;
|
|
500
|
+
const fetchedAt = timestamp(provider?.fetchedAt ?? usage.fetchedAt);
|
|
501
|
+
const windowEnd = timestamp(window.resetsAt);
|
|
502
|
+
if (windowEnd !== null && windowEnd <= this.now() && (fetchedAt === null || fetchedAt <= windowEnd)) continue;
|
|
503
|
+
const hasBackground = nativeEnabled && agent.status !== "running" && await nativeRunning(agent.id);
|
|
504
|
+
if (agent.status !== "running" && !quotaFailed && !hasBackground) continue;
|
|
505
|
+
// Do not stop a new turn based on a snapshot from before the upstream read.
|
|
506
|
+
const latest = (await client.fetchAgent(agent.id))?.agent;
|
|
507
|
+
if (!latest || latest.status !== agent.status || latest.provider !== agent.provider || latest.archivedAt ||
|
|
508
|
+
(!nativeEnabled && latest.pendingPermissions?.length) || (latest.lastUserMessageAt ?? null) !== (agent.lastUserMessageAt ?? null) ||
|
|
509
|
+
latest.activeTurn?.turnId !== agent.activeTurn?.turnId) continue;
|
|
510
|
+
if (this.stopped) return;
|
|
511
|
+
let suspension: Pause["suspension"];
|
|
512
|
+
if (nativeEnabled && (!quotaFailed || hasBackground)) {
|
|
513
|
+
if (!client.getDaemonStatus) throw new Error("Daemon process identity is unavailable.");
|
|
514
|
+
daemonPid ??= (await client.getDaemonStatus()).pid;
|
|
515
|
+
suspension = await this.processes!.discover(latest, daemonPid) ?? undefined;
|
|
516
|
+
if (!suspension) {
|
|
517
|
+
// Never pretend a foreground interrupt also covers native children.
|
|
518
|
+
this.error = process.platform === "linux"
|
|
519
|
+
? "Native worker protection could not confirm ownership of a provider process. It will retry automatically."
|
|
520
|
+
: "Native worker protection requires Linux. Turn it off to use foreground interruption on this host.";
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const reset = timestamp(window.resetsAt);
|
|
525
|
+
const pause: Pause = {
|
|
526
|
+
id: randomUUID(), agentId: agent.id, workspaceId: agent.workspaceId, provider: agent.provider, title: agent.title ?? null,
|
|
527
|
+
windowId: window.id, anchorResetsAt: reset === null ? null : new Date(reset).toISOString(),
|
|
528
|
+
pausedAt: new Date(this.now()).toISOString(), lastUserMessageAt: agent.lastUserMessageAt ?? null,
|
|
529
|
+
turnId: agent.activeTurn?.turnId ?? null, phase: quotaFailed && !suspension ? "waiting" : "pausing", note: null, settledAt: null,
|
|
530
|
+
wasQuotaFailure: quotaFailed, daemonIdentity: currentDaemon,
|
|
531
|
+
...(suspension ? { suspension } : {}),
|
|
532
|
+
};
|
|
533
|
+
state.pauses.push(pause);
|
|
534
|
+
await this.save(state);
|
|
535
|
+
if (quotaFailed) this.quotaFailures.delete(agent.id);
|
|
536
|
+
if (quotaFailed && !suspension) continue;
|
|
537
|
+
try {
|
|
538
|
+
if (pause.suspension) await this.processes!.freeze(pause.suspension, () => this.store.write(state));
|
|
539
|
+
else await client.cancelAgent(agent.id);
|
|
540
|
+
pause.phase = "waiting";
|
|
541
|
+
} catch {
|
|
542
|
+
if (pause.suspension) {
|
|
543
|
+
// Release partial suspensions before asking for manual inspection.
|
|
544
|
+
await this.processes!.resume(pause.suspension);
|
|
545
|
+
delete pause.suspension;
|
|
546
|
+
}
|
|
547
|
+
pause.phase = "attention";
|
|
548
|
+
pause.note = "The pause could not be confirmed. Check the session before continuing manually.";
|
|
549
|
+
}
|
|
550
|
+
await this.save(state);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|