paseo-smart-session 3.5.0 → 3.6.1

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,163 @@
1
+ /**
2
+ * The record of what this session has already been asked, and what it answered.
3
+ *
4
+ * Asking once and never again lets a session drift; asking every turn is nagging.
5
+ * So the ask is latched, re-armed by growth rather than by a clock alone, and can
6
+ * be switched off for a while by the agent itself — that last part is what makes it
7
+ * a question instead of an announcement.
8
+ *
9
+ * One file per agent rather than one shared file. `state/<agentId>.md` already
10
+ * works that way, and it means the `Stop` hook and the `defer_compaction` tool —
11
+ * two separate processes — can never be writing the same file at the same moment.
12
+ */
13
+
14
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
15
+ import { randomUUID } from "node:crypto";
16
+ import { join } from "node:path";
17
+
18
+ import { pluginDir } from "./pointer.mjs";
19
+
20
+ /** Asks per compaction epoch, after which the session is left to its own judgement. */
21
+ export const MAX_ASKS = 3;
22
+
23
+ /**
24
+ * Further occupancy, in percentage points of the window, that re-arms the ask.
25
+ *
26
+ * Time alone is the wrong trigger: a session parked at 31% for an hour has not
27
+ * changed its situation, and one that went 31% to 55% in ten minutes plainly has.
28
+ */
29
+ export const RE_ASK_GROWTH_PCT = 10;
30
+
31
+ /**
32
+ * `now*` is the upper line's own record. It is kept apart from the end-of-turn ask
33
+ * so that having been asked politely at the lower line never silences the one ask
34
+ * that says "stop pushing on", and `deferredAt` survives an answered ask so the
35
+ * upper line can tell "deferred after I asked" from "never answered".
36
+ */
37
+ const EMPTY = {
38
+ askCount: 0,
39
+ lastAskedAt: null,
40
+ askedAtPct: null,
41
+ deferredUntil: null,
42
+ deferReason: null,
43
+ deferredAt: null,
44
+ nowAskCount: 0,
45
+ nowAskedAt: null,
46
+ };
47
+
48
+ /**
49
+ * Keyed by Paseo agent id where there is one, because that survives compaction
50
+ * whatever Claude Code decides to do with the session id.
51
+ */
52
+ export function ledgerPath(sessionId, agentId = process.env.PASEO_AGENT_ID) {
53
+ const key = agentId !== undefined && agentId !== "" ? agentId : sessionId;
54
+ if (typeof key !== "string" || key === "") return null;
55
+ return join(pluginDir(), "asks", `${key.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
56
+ }
57
+
58
+ export function readLedger(sessionId, agentId) {
59
+ const path = ledgerPath(sessionId, agentId ?? undefined);
60
+ if (path === null) return { ...EMPTY };
61
+ try {
62
+ return { ...EMPTY, ...JSON.parse(readFileSync(path, "utf8")) };
63
+ } catch {
64
+ return { ...EMPTY };
65
+ }
66
+ }
67
+
68
+ export function writeLedger(sessionId, ledger, agentId) {
69
+ const path = ledgerPath(sessionId, agentId ?? undefined);
70
+ if (path === null) return;
71
+ try {
72
+ mkdirSync(join(pluginDir(), "asks"), { recursive: true });
73
+ const temp = `${path}.${randomUUID()}.tmp`;
74
+ writeFileSync(temp, JSON.stringify(ledger), "utf8");
75
+ renameSync(temp, path);
76
+ } catch {
77
+ // A hook that cannot persist its latch must still not break the turn.
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Forgets everything, because the window just emptied.
83
+ *
84
+ * A compaction is a new epoch: the occupancy the last ask was about is gone, and a
85
+ * session that fills up again deserves to be asked again.
86
+ */
87
+ export function resetLedger(sessionId) {
88
+ const path = ledgerPath(sessionId);
89
+ if (path === null) return;
90
+ try {
91
+ rmSync(path, { force: true });
92
+ } catch {
93
+ // A stale ledger costs one missed ask, never a failed turn.
94
+ }
95
+ }
96
+
97
+ /** The agent's own answer: not now, and here is for how long. */
98
+ export function defer(sessionId, minutes, reason, agentId) {
99
+ const ledger = readLedger(sessionId, agentId);
100
+ const until = new Date(Date.now() + minutes * 60_000).toISOString();
101
+ writeLedger(sessionId, { ...ledger, deferredUntil: until, deferReason: reason, deferredAt: new Date().toISOString() }, agentId);
102
+ return until;
103
+ }
104
+
105
+ /**
106
+ * Whether to ask, given what has already been said and how much has changed.
107
+ *
108
+ * `pct` is the occupancy now; `askedAtPct` the occupancy the last ask was about.
109
+ */
110
+ function deferred(ledger, now) {
111
+ if (ledger.deferredUntil === null) return false;
112
+ const until = Date.parse(ledger.deferredUntil);
113
+ return Number.isFinite(until) && now < until;
114
+ }
115
+
116
+ export function shouldAsk(ledger, pct, now = Date.now()) {
117
+ if (deferred(ledger, now)) return false;
118
+ if (ledger.askCount >= MAX_ASKS) return false;
119
+ if (ledger.askedAtPct === null) return true;
120
+ return pct >= ledger.askedAtPct + RE_ASK_GROWTH_PCT;
121
+ }
122
+
123
+ /** Records that the question was actually put to the model. */
124
+ export function recordAsk(sessionId, ledger, pct) {
125
+ writeLedger(sessionId, {
126
+ ...ledger,
127
+ askCount: ledger.askCount + 1,
128
+ lastAskedAt: new Date().toISOString(),
129
+ askedAtPct: pct,
130
+ // An answered ask clears the deferral it was waiting out.
131
+ deferredUntil: null,
132
+ deferReason: null,
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Whether to ask right away, mid-turn, at the upper line.
138
+ *
139
+ * Once per compaction epoch. It comes back only if the agent answered the last one
140
+ * with a deferral and that deferral has run out — ignoring it earns no repeat, so a
141
+ * long turn hears it once rather than after every tool call — and never more than
142
+ * `MAX_ASKS` times in all. Any live deferral, from either ask, holds it off: the
143
+ * agent's "not now" is one answer, not one per line.
144
+ */
145
+ export function shouldAskNow(ledger, now = Date.now()) {
146
+ if (deferred(ledger, now)) return false;
147
+ if (ledger.nowAskCount === 0 || ledger.nowAskedAt === null) return true;
148
+ if (ledger.nowAskCount >= MAX_ASKS) return false;
149
+ const asked = Date.parse(ledger.nowAskedAt);
150
+ const answered = ledger.deferredAt === null ? Number.NaN : Date.parse(ledger.deferredAt);
151
+ return Number.isFinite(asked) && Number.isFinite(answered) && answered >= asked;
152
+ }
153
+
154
+ /** Records that the upper line's question was put to the model. */
155
+ export function recordNowAsk(sessionId, ledger) {
156
+ writeLedger(sessionId, {
157
+ ...ledger,
158
+ nowAskCount: ledger.nowAskCount + 1,
159
+ nowAskedAt: new Date().toISOString(),
160
+ deferredUntil: null,
161
+ deferReason: null,
162
+ });
163
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * What every hook needs to know about the session it is running in.
3
+ *
4
+ * Two hooks now answer "how full is this context" — `context-threshold.mjs` on
5
+ * `PostToolUse` and `ask-compact.mjs` on `Stop` — and they must not disagree. One
6
+ * reading them differently from the other would mean the agent hears the number and
7
+ * the advice from two different sessions.
8
+ *
9
+ * Everything here reads the tail of a file rather than the whole of it: these run
10
+ * on the hot path, and the newest row is the only one that matters.
11
+ */
12
+
13
+ import { closeSync, fstatSync, openSync, readdirSync, readFileSync, readSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ import { pluginDir } from "./pointer.mjs";
17
+
18
+ /** Enough tail to contain the last assistant message in any realistic transcript. */
19
+ const TAIL_BYTES = 256 * 1024;
20
+
21
+ /**
22
+ * Defaults mirroring thresholds.shared.ts, used when the plugin has not written a
23
+ * settings file yet. Kept in sync by a test that runs the hooks and checks where
24
+ * they fire against the shared module's numbers.
25
+ *
26
+ * Two profiles because a percentage of the window is the wrong unit on its own:
27
+ * what tires a context is the absolute prefix re-read every turn, so 30% of a
28
+ * million tokens is a heavier context than 85% of two hundred thousand.
29
+ */
30
+ export const DEFAULT_THRESHOLDS = {
31
+ largeWindowFrom: 400_000,
32
+ large: { notice: 15, closing: 22, compact: 30, now: 40 },
33
+ small: { notice: 60, closing: 75, compact: 85, now: 90 },
34
+ };
35
+
36
+ export const BANDS = ["notice", "closing", "compact"];
37
+
38
+ export const DEFAULT_SETTINGS = {
39
+ enabled: true,
40
+ showPill: true,
41
+ autoEnrol: true,
42
+ freshStateMinutes: 30,
43
+ thresholds: DEFAULT_THRESHOLDS,
44
+ };
45
+
46
+ /** The plugin's settings are the single source of truth when they exist. */
47
+ export function readSettings() {
48
+ try {
49
+ const raw = JSON.parse(readFileSync(join(pluginDir(), "settings.json"), "utf8"));
50
+ const merged = { ...DEFAULT_THRESHOLDS, ...(raw.thresholds ?? {}) };
51
+ return {
52
+ // Absent means an install that predates the switch, which was always meant
53
+ // to be on once it could only ask rather than act.
54
+ enabled: raw.enabled !== false,
55
+ showPill: raw.showPill !== false,
56
+ autoEnrol: raw.autoEnrol !== false,
57
+ freshStateMinutes:
58
+ typeof raw.freshStateMinutes === "number" && raw.freshStateMinutes > 0
59
+ ? raw.freshStateMinutes
60
+ : DEFAULT_SETTINGS.freshStateMinutes,
61
+ thresholds: {
62
+ largeWindowFrom: merged.largeWindowFrom ?? DEFAULT_THRESHOLDS.largeWindowFrom,
63
+ large: withUpperLine(merged.large, DEFAULT_THRESHOLDS.large),
64
+ small: withUpperLine(merged.small, DEFAULT_THRESHOLDS.small),
65
+ },
66
+ };
67
+ } catch {
68
+ return DEFAULT_SETTINGS;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * A saved profile, with the upper line filled in when it predates one.
74
+ *
75
+ * Mirrors `normalizeProfile` in thresholds.ts: the saved lower line is kept, and an
76
+ * absent upper line keeps the default's distance above it, never falling below it.
77
+ */
78
+ export function withUpperLine(saved, fallback) {
79
+ const profile = { ...fallback, ...(saved ?? {}) };
80
+ const compact = profile.compact;
81
+ const migrated = Math.min(99, Math.max(fallback.now, compact + (fallback.now - fallback.compact)));
82
+ const raw = typeof saved?.now === "number" && Number.isFinite(saved.now) ? saved.now : migrated;
83
+ return { ...profile, now: Math.min(99, Math.max(Math.min(99, compact + 1), raw)) };
84
+ }
85
+
86
+ export function profileFor(maxTokens, thresholds) {
87
+ return maxTokens >= thresholds.largeWindowFrom ? thresholds.large : thresholds.small;
88
+ }
89
+
90
+ /**
91
+ * Whether this agent is one the governor may speak to.
92
+ *
93
+ * The same rule settings.server.ts resolves, duplicated here because a hook on the
94
+ * hot path must not open a daemon connection to answer it: an explicit answer in
95
+ * `enrolment.json` outranks the state-file inference in both directions, and the
96
+ * inference itself only counts while `autoEnrol` is on.
97
+ */
98
+ export function isEnrolled(agentId, settings = readSettings()) {
99
+ if (typeof agentId !== "string" || agentId === "") return false;
100
+ try {
101
+ const overrides = JSON.parse(readFileSync(join(pluginDir(), "enrolment.json"), "utf8"));
102
+ if (typeof overrides?.[agentId] === "boolean") return overrides[agentId];
103
+ } catch {
104
+ // No file, or one we cannot read: nobody has overridden anything.
105
+ }
106
+ if (!settings.autoEnrol) return false;
107
+ try {
108
+ return readdirSync(join(pluginDir(), "state")).includes(`${agentId}.md`);
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /** The tail of a file as text, or null if it cannot be read. */
115
+ function tail(path, bytes) {
116
+ let fd;
117
+ try {
118
+ fd = openSync(path, "r");
119
+ } catch {
120
+ return null;
121
+ }
122
+ try {
123
+ const size = fstatSync(fd).size;
124
+ const length = Math.min(size, bytes);
125
+ const buffer = Buffer.allocUnsafe(length);
126
+ readSync(fd, buffer, 0, length, size - length);
127
+ return buffer.toString("utf8");
128
+ } catch {
129
+ return null;
130
+ } finally {
131
+ closeSync(fd);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Context occupancy from the last assistant message's usage.
137
+ *
138
+ * input + cache_read + cache_creation + output is the same arithmetic Claude Code
139
+ * itself documents for a resumed session's `context_tokens`, so it is the
140
+ * sanctioned way to ask "how much of the window is currently occupied".
141
+ */
142
+ export function readContextTokens(transcriptPath) {
143
+ const text = tail(transcriptPath, TAIL_BYTES);
144
+ if (text === null) return null;
145
+ const lines = text.split("\n");
146
+
147
+ // Backwards: the newest assistant message wins, and a partial first line from
148
+ // slicing mid-file simply fails to parse and is skipped.
149
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
150
+ const line = lines[index];
151
+ if (line === undefined || line === "" || !line.includes('"usage"')) continue;
152
+ let entry;
153
+ try {
154
+ entry = JSON.parse(line);
155
+ } catch {
156
+ continue;
157
+ }
158
+ if (entry?.type !== "assistant") continue;
159
+ // A subagent's usage is its own window, not the main thread's.
160
+ if (entry.isSidechain === true) continue;
161
+ const usage = entry.message?.usage;
162
+ if (usage === undefined) continue;
163
+ const used =
164
+ (usage.input_tokens ?? 0) +
165
+ (usage.cache_read_input_tokens ?? 0) +
166
+ (usage.cache_creation_input_tokens ?? 0) +
167
+ (usage.output_tokens ?? 0);
168
+ return { used, model: entry.message?.model ?? null };
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Models whose context window is not the current default of 1M.
175
+ *
176
+ * The transcript records the resolved model id (`claude-opus-5`), never the config
177
+ * alias (`opus[1m]`), so the suffix is not a signal. Every current Opus, Sonnet and
178
+ * Fable model has a 1M window; Haiku and the older generations do not.
179
+ */
180
+ const SMALL_WINDOW = /haiku|-3-|sonnet-3|opus-3|sonnet-4-5|opus-4-5|sonnet-4-20|opus-4-20/;
181
+
182
+ /**
183
+ * The window size Paseo reported for this agent, from the recorder's own log.
184
+ *
185
+ * Read from the tail, like the transcript: this is the hot path, and the newest row
186
+ * is the only one that matters.
187
+ */
188
+ function recordedWindow() {
189
+ const agentId = process.env.PASEO_AGENT_ID;
190
+ if (agentId === undefined || agentId === "") return null;
191
+ const month = new Date().toISOString().slice(0, 7);
192
+ const text = tail(join(pluginDir(), `context-${month}.jsonl`), 64 * 1024);
193
+ if (text === null) return null;
194
+
195
+ const lines = text.split("\n");
196
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
197
+ const line = lines[index];
198
+ if (line === undefined || !line.includes(agentId)) continue;
199
+ try {
200
+ const row = JSON.parse(line);
201
+ if (row.agentId === agentId && typeof row.maxTokens === "number" && row.maxTokens > 0) {
202
+ return row.maxTokens;
203
+ }
204
+ } catch {
205
+ continue;
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * The window size for this session.
213
+ *
214
+ * Getting this wrong is worse than not warning at all: assuming 200k on a 1M
215
+ * session warns at a fifth of the real occupancy and reports negative headroom.
216
+ * So the first choice is what Paseo actually measured for this agent — recorded by
217
+ * the plugin, read from the tail of its log — and the model table is the fallback.
218
+ */
219
+ export function windowFor(model) {
220
+ const override = Number(process.env.SMART_SESSION_CONTEXT_WINDOW ?? "");
221
+ if (Number.isFinite(override) && override > 0) return override;
222
+
223
+ const recorded = recordedWindow();
224
+ if (recorded !== null) return recorded;
225
+
226
+ if (typeof model === "string" && SMALL_WINDOW.test(model)) return 200_000;
227
+ return 1_000_000;
228
+ }
229
+
230
+ /**
231
+ * Occupancy, the window it is measured against, and the bands that apply.
232
+ *
233
+ * Returns null when the transcript says nothing yet — a session with no completed
234
+ * assistant turn has no usage to read, and guessing would be worse than silence.
235
+ */
236
+ export function occupancy(transcriptPath, settings = readSettings()) {
237
+ const reading = readContextTokens(transcriptPath);
238
+ if (reading === null) return null;
239
+ const max = windowFor(reading.model);
240
+ return {
241
+ used: reading.used,
242
+ max,
243
+ model: reading.model,
244
+ pct: Math.floor((reading.used / max) * 100),
245
+ profile: profileFor(max, settings.thresholds),
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Names Smart Session's tools in agent-facing text as this agent has them. On Paseo 0.15+ they are
251
+ * served through Paseo's own MCP with a `smart_` prefix (`SMART_SESSION_TOOLS=native` in the
252
+ * agent's environment); sessions opened before that, and the stdio fallback, keep the bare names.
253
+ */
254
+ const TOOL_MENTION = /\b(call|Call|with) (checkpoint|request_compaction|defer_compaction|context_status|budget_status)\b/g;
255
+ export function named(text, native = toolsMode() === "native") {
256
+ return native ? text.replace(TOOL_MENTION, "$1 smart_$2") : text;
257
+ }
258
+
259
+ /**
260
+ * Which tools this session has. Native tools live in the plugin process, so a plugin update or reload detaches them
261
+ * from every open session until it reopens (Paseo 0.15.2 reopens a session before that agent's next prompt). The
262
+ * worker records its instance in `tools-instance.json` and each native attach the instance that attached it in
263
+ * `tools/native-<agentId>.json`; a mismatch means "detached".
264
+ */
265
+ export function toolsMode(env = process.env) {
266
+ if (env.SMART_SESSION_TOOLS !== "native") return env.SMART_SESSION_TOOLS === "stdio" ? "stdio" : "legacy";
267
+ const read = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } };
268
+ const current = read(join(pluginDir(), "tools-instance.json"));
269
+ const agentId = env.PASEO_AGENT_ID;
270
+ const mine = agentId ? read(join(pluginDir(), "tools", `native-${agentId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`)) : null;
271
+ return current?.instance && mine?.instance !== current.instance ? "detached" : "native";
272
+ }
273
+
274
+ /** What a detached session is told: the same tools, one call each, through mcp.mjs beside this plugin's hooks. */
275
+ export function detachedNotice(bridge) {
276
+ return `Smart Session was updated while this session was open, so its smart_* tools are detached and fail with "Tool not found" until this session reopens (Paseo reopens it before your next prompt). Until then run the same tools by their plain names: node ${JSON.stringify(bridge)} call <tool> '<json>' (for example: call checkpoint '{"current_step":"..."}'). Do not mention this to the user.`;
277
+ }
278
+
279
+ /** Agent-facing text for this session's tools: smart_ names, plain names, or plain names plus the bridge. */
280
+ export function forTools(text, bridge) {
281
+ const mode = toolsMode();
282
+ if (mode === "native") return named(text, true);
283
+ return mode === "detached" ? `${text}\n\n${detachedNotice(bridge)}` : text;
284
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The state-file pointer, and the two ways it can reach the model.
3
+ *
4
+ * Compaction is the one moment the agent is deciding what it knows, so it is the
5
+ * one moment worth spending an injection on. But `PostCompact` — the event that
6
+ * knows a compaction happened — cannot inject: Claude Code validates
7
+ * `hookSpecificOutput.hookEventName` against a union that does not contain it, and
8
+ * rejects the whole output. So the pointer is written here as a small pending note
9
+ * and delivered by whichever hook speaks first:
10
+ *
11
+ * - `SessionStart` with `source: "compact"`, which fires in the emptied context
12
+ * and does accept `additionalContext`; or
13
+ * - the next `PostToolUse`, which is guaranteed to run.
14
+ *
15
+ * Whoever delivers it clears it, so it is said exactly once.
16
+ *
17
+ * Keyed by Paseo agent id when there is one, because that survives compaction
18
+ * whatever Claude Code decides to do with the session id.
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ export function pluginDir() {
26
+ const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
27
+ return join(home, "plugin-data", "smart-session");
28
+ }
29
+
30
+ /** The same derivation the agent's `checkpoint` tool uses. */
31
+ export function statePath() {
32
+ const override = process.env.SMART_SESSION_STATE_FILE;
33
+ if (override !== undefined && override !== "") return override;
34
+ const agentId = process.env.PASEO_AGENT_ID;
35
+ if (agentId === undefined || agentId === "") return null;
36
+ return join(pluginDir(), "state", `${agentId}.md`);
37
+ }
38
+
39
+ /** Seconds since the state file was last written, or null if there isn't one. */
40
+ export function stateAgeSeconds(path) {
41
+ if (path === null || !existsSync(path)) return null;
42
+ try {
43
+ return Math.round((Date.now() - statSync(path).mtimeMs) / 1000);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ const STALE_AFTER_SECONDS = 30 * 60;
50
+
51
+ /**
52
+ * What the continuation needs to hear, given what is actually on disk.
53
+ *
54
+ * Returns null when there is nowhere to point — no agent id, no override — since
55
+ * naming no file at all would be worse than silence.
56
+ */
57
+ export function pointerText(path = statePath(), age = stateAgeSeconds(path)) {
58
+ if (path === null) return null;
59
+ if (age === null) {
60
+ return (
61
+ `No durable state file exists yet for this session (expected at ${path}). Everything not in the summary above is now gone. ` +
62
+ "Before continuing, write down the goal, the step in progress and its exact next action, decisions made and why, and every approach already tried and rejected — then keep it current, so the next compaction costs nothing."
63
+ );
64
+ }
65
+ const lines = [
66
+ `Durable task state for this session is at ${path}. Re-read it before acting. Where it disagrees with the summary above, the file is correct — it was written deliberately; the summary was compressed automatically.`,
67
+ ];
68
+ // A state file older than the work it is supposed to describe is worse than
69
+ // none, because it reads as authoritative. Say so plainly.
70
+ if (age > STALE_AFTER_SECONDS) {
71
+ lines.push(
72
+ `Note: that file was last written ${Math.round(age / 60)} minutes ago, so it may predate recent work. Reconcile it against the summary and update it before continuing.`,
73
+ );
74
+ }
75
+ return lines.join(" ");
76
+ }
77
+
78
+ function pointerPath(sessionId) {
79
+ const agentId = process.env.PASEO_AGENT_ID;
80
+ const key = agentId !== undefined && agentId !== "" ? agentId : sessionId;
81
+ if (typeof key !== "string" || key === "") return null;
82
+ return join(pluginDir(), "pointers", `${key.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
83
+ }
84
+
85
+ function readNote(sessionId) {
86
+ const path = pointerPath(sessionId);
87
+ if (path === null) return null;
88
+ try {
89
+ return JSON.parse(readFileSync(path, "utf8"));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function writeNote(sessionId, note) {
96
+ const path = pointerPath(sessionId);
97
+ if (path === null) return;
98
+ try {
99
+ mkdirSync(join(pluginDir(), "pointers"), { recursive: true });
100
+ writeFileSync(path, JSON.stringify(note), "utf8");
101
+ } catch {
102
+ // A hook that cannot persist its note must still not break the turn.
103
+ }
104
+ }
105
+
106
+ /** Leaves the pointer where either delivery path will find it. */
107
+ export function writePending(sessionId, text) {
108
+ if (text === null) return;
109
+ writeNote(sessionId, { at: new Date().toISOString(), text });
110
+ }
111
+
112
+ /**
113
+ * Records that the pointer has already reached the model for this compaction.
114
+ *
115
+ * Measured on a real compaction: `SessionStart:compact` fires ~55ms *before*
116
+ * `PostCompact`, so without this the later event would queue a note that the next
117
+ * tool call repeats. The marker is left in place rather than deleted — the next
118
+ * compaction overwrites it, and an absent file has to keep meaning "nothing
119
+ * pending".
120
+ */
121
+ export function markDelivered(sessionId) {
122
+ writeNote(sessionId, { at: new Date().toISOString(), delivered: true });
123
+ }
124
+
125
+ /** Whether this compaction's pointer was already said, within `withinMs`. */
126
+ export function deliveredRecently(sessionId, withinMs) {
127
+ const note = readNote(sessionId);
128
+ if (note?.delivered !== true) return false;
129
+ const at = Date.parse(note.at ?? "");
130
+ return Number.isFinite(at) && Date.now() - at < withinMs;
131
+ }
132
+
133
+ /** The pending pointer, without consuming it — nothing is cleared until it is said. */
134
+ export function readPending(sessionId) {
135
+ const note = readNote(sessionId);
136
+ if (note === null || note.delivered === true) return null;
137
+ return typeof note.text === "string" && note.text !== "" ? note.text : null;
138
+ }
139
+
140
+ /** Called only once the pointer has actually been written to stdout. */
141
+ export function clearPending(sessionId) {
142
+ const path = pointerPath(sessionId);
143
+ if (path === null) return;
144
+ try {
145
+ rmSync(path, { force: true });
146
+ } catch {
147
+ // A stale note costs one repeated sentence, never a failed turn.
148
+ }
149
+ }
@@ -13,7 +13,9 @@
13
13
  * machine's real Claude Code settings just because something imported a function.
14
14
  */
15
15
 
16
- import { install } from "./install.ts";
16
+ import { lifecycle } from "../shared/lifecycle.ts";
17
+ import { install, refreshPointer, resolvePluginDir } from "./install.ts";
18
+ import { removeGlobalRegistrations } from "./mcp-registrations.ts";
17
19
 
18
20
  void install()
19
21
  .then((report) => {
@@ -33,3 +35,31 @@ void install()
33
35
  }
34
36
  })
35
37
  .catch((error: unknown) => console.error("[smart-session] hook install failed", String(error)));
38
+
39
+ // Older versions registered the agent tools globally; they are attached per launch now. Only an
40
+ // install Paseo actually loads from this PASEO_HOME does this, so a check that loads the bundle
41
+ // against a sandbox (check-teardown.mjs) never reaches the machine's real Claude and Codex homes.
42
+ void resolvePluginDir()
43
+ .then((directory) => (directory === null ? [] : removeGlobalRegistrations()))
44
+ .then((results) => {
45
+ for (const result of results) {
46
+ if (result.status === "removed") console.log(`[smart-session] removed the global MCP registration from ${result.path} (backup beside it)`);
47
+ if (result.status === "error" || result.status === "unavailable") console.error(`[smart-session] could not remove the global MCP registration from ${result.path}: ${result.error ?? "no claude CLI on PATH"}`);
48
+ }
49
+ })
50
+ .catch(() => undefined);
51
+
52
+ // Self-heal. The hooks run through shims whose pointer names an install directory, and
53
+ // Paseo deletes an install directory on update. install() above already points the shims
54
+ // at the running version; this repairs the pointer if its target disappears later. A
55
+ // healthy pointer costs a few stat calls and writes nothing, hence ten minutes.
56
+ const HEAL_MS = 10 * 60_000;
57
+ const heal = setInterval(() => {
58
+ void refreshPointer()
59
+ .then((moved) => {
60
+ if (moved) console.log("[smart-session] re-pointed the hook shims at the running install");
61
+ })
62
+ .catch(() => undefined);
63
+ }, HEAL_MS);
64
+ heal.unref?.();
65
+ lifecycle.teardowns.push(() => clearInterval(heal));