paseo-smart-session 1.2.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,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A `Stop` hook that asks the session to compact itself — and nothing else.
4
+ *
5
+ * This is the piece that keeps compaction agent-mandated. The plugin never decides
6
+ * to compact a session and never sends it a message to say so; it puts the question
7
+ * at the one moment where acting on it is safe, and the agent answers with its own
8
+ * tool call. What Paseo sends is the `/compact` the agent asked for.
9
+ *
10
+ * `Stop` is the right event for three reasons, all of them verified against Claude
11
+ * Code 2.1.263 rather than assumed (`RESEARCH.md` §3.4):
12
+ *
13
+ * - it fires at a turn boundary, which is exactly the moment the governor used to
14
+ * sit in a fifteen-second poll waiting for;
15
+ * - its `additionalContext` is documented as "non-error feedback delivered to the
16
+ * model; the conversation continues so the model can act on it", so the ask
17
+ * costs no extra turn and needs no `decision: "block"`; and
18
+ * - its payload carries `background_tasks`, which is how a hook tells "this
19
+ * session is done" from "this session is waiting on work still in flight".
20
+ *
21
+ * It stays completely silent unless there is something worth stopping for.
22
+ */
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ import { isEnrolled, occupancy, readSettings } from "./context.mjs";
28
+ import { pluginDir, stateAgeSeconds, statePath } from "./pointer.mjs";
29
+ import { readLedger, recordAsk, shouldAsk } from "./asks.mjs";
30
+
31
+ /**
32
+ * A compaction that had to be repeated within minutes is not solving the problem —
33
+ * something in the loop is refilling the window as fast as it is emptied, and
34
+ * another compaction just burns tokens on summarizing. Claude Code detects the same
35
+ * pathology and calls it thrashing.
36
+ */
37
+ const THRASH_WINDOW_MS = 15 * 60_000;
38
+
39
+ /** The governor's queue, read directly: a hook must not open a daemon connection. */
40
+ function compactions() {
41
+ try {
42
+ const parsed = JSON.parse(readFileSync(join(pluginDir(), "compactions.json"), "utf8"));
43
+ return Array.isArray(parsed?.items) ? parsed.items : [];
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ /** Already asked and waiting to be delivered: there is nothing to add. */
50
+ function alreadyQueued(items, agentId) {
51
+ return items.some(
52
+ (item) => item.agentId === agentId && (item.state === "pending" || item.state === "sending"),
53
+ );
54
+ }
55
+
56
+ function thrashing(items, agentId, now = Date.now()) {
57
+ return items.some(
58
+ (item) =>
59
+ item.agentId === agentId &&
60
+ item.state === "sent" &&
61
+ item.settledAt !== null &&
62
+ now - Date.parse(item.settledAt) < THRASH_WINDOW_MS,
63
+ );
64
+ }
65
+
66
+ /**
67
+ * The question.
68
+ *
69
+ * On a large window the headline is the token count, not the percentage: "30% full"
70
+ * sounds like there is plenty of room, and 300,000 tokens does not.
71
+ *
72
+ * It has to be answerable in one turn, so it names the tools and says what to do
73
+ * with each, and it has to be refusable, or it is not a question — hence the
74
+ * deferral, which the agent is told about in the same breath.
75
+ */
76
+ function ask({ used, max, pct }, path, age, freshMinutes) {
77
+ const stale = age === null || age > freshMinutes * 60;
78
+ const lines = [
79
+ `Smart Compact: this session holds ${used.toLocaleString()} tokens (${pct}% of a ${max.toLocaleString()}-token window).`,
80
+ max >= 400_000
81
+ ? "That is worth compacting now even though the window is nowhere near full — what costs you is the prefix re-read on every turn, not the ceiling."
82
+ : "This is the point where recall degrades noticeably and every turn re-reads an expensive prefix.",
83
+ "Compacting is your call, not this plugin's, and nothing will happen unless you ask. Do one of these before you stop:",
84
+ ];
85
+
86
+ if (stale) {
87
+ lines.push(
88
+ age === null
89
+ ? `1. There is no task state on disk yet. Call checkpoint — goal, the step in progress and its exact next action, decisions and why, and every approach already tried and rejected — then call request_compaction. Anything you do not write down is gone after the compaction.`
90
+ : `1. Your task state at ${path} was last written ${Math.round(age / 60)} minutes ago, so it may predate recent work. Call checkpoint to bring it up to date, then call request_compaction.`,
91
+ );
92
+ } else {
93
+ lines.push(
94
+ `1. Your task state at ${path} is current, so this is cheap: call request_compaction with a one-line reason. If work remains, ask it to continue (and optionally choose the follow-up message); if the task is finished, ask it not to continue.`,
95
+ );
96
+ }
97
+
98
+ lines.push(
99
+ "2. Or, if this is the wrong moment — mid-refactor, a tool sequence half finished, an answer the user is waiting on — call defer_compaction with a reason and you will not be asked again for a while.",
100
+ "Either way, do it now and then stop. A queued compaction is delivered as a real /compact once this session goes idle; a follow-up turn is sent only if you requested one.",
101
+ );
102
+ return lines.join("\n");
103
+ }
104
+
105
+ /**
106
+ * What to say instead, when compacting is not the answer.
107
+ *
108
+ * Said once, as an ask, so it counts against the same latch: repeating it every
109
+ * turn would be the nagging this hook exists to avoid.
110
+ */
111
+ function thrashAdvice({ used, pct }) {
112
+ return [
113
+ `Smart Compact: this session refilled to ${used.toLocaleString()} tokens (${pct}%) within minutes of its last compaction.`,
114
+ "Compacting again would not help — something in this loop is reading far more than it keeps, so the window would refill just as fast and you would pay for another summary.",
115
+ "Write what you have learned to your state file with checkpoint, then either change the approach so it stops re-reading the same material, or finish and let a fresh session take the rest.",
116
+ ].join("\n");
117
+ }
118
+
119
+ function emit(text) {
120
+ process.stdout.write(
121
+ `${JSON.stringify({
122
+ hookSpecificOutput: { hookEventName: "Stop", additionalContext: text },
123
+ suppressOutput: true,
124
+ })}\n`,
125
+ );
126
+ }
127
+
128
+ async function main() {
129
+ const raw = await new Promise((resolve) => {
130
+ let input = "";
131
+ process.stdin.setEncoding("utf8");
132
+ process.stdin.on("data", (chunk) => (input += chunk));
133
+ process.stdin.on("end", () => resolve(input));
134
+ });
135
+
136
+ let event;
137
+ try {
138
+ event = JSON.parse(raw);
139
+ } catch {
140
+ return; // Not our business to complain about a malformed event.
141
+ }
142
+
143
+ // Claude Code overrides a hook that keeps a turn alive too many times in a row,
144
+ // and tells you to check this flag. A second ask would also be arriving into the
145
+ // turn the first one created, which is not a boundary at all.
146
+ if (event.stop_hook_active === true) return;
147
+
148
+ // Subagents have their own short-lived windows and their own stop conditions.
149
+ if (event.agent_id !== undefined && event.agent_id !== null) return;
150
+
151
+ // "Stopped" and "waiting for work that is still running" are different states.
152
+ // Compacting through the second one discards the context that work reports into.
153
+ if (Array.isArray(event.background_tasks) && event.background_tasks.length > 0) return;
154
+
155
+ const transcriptPath = event.transcript_path;
156
+ const sessionId = event.session_id;
157
+ if (typeof transcriptPath !== "string" || typeof sessionId !== "string") return;
158
+
159
+ const settings = readSettings();
160
+ if (!settings.enabled) return;
161
+
162
+ const agentId = process.env.PASEO_AGENT_ID ?? null;
163
+ if (!isEnrolled(agentId, settings)) return;
164
+
165
+ const now = occupancy(transcriptPath, settings);
166
+ if (now === null || now.pct < now.profile.compact) return;
167
+
168
+ const ledger = readLedger(sessionId);
169
+ if (!shouldAsk(ledger, now.pct)) return;
170
+
171
+ const queue = compactions();
172
+ if (alreadyQueued(queue, agentId)) return;
173
+
174
+ const path = statePath();
175
+ const age = stateAgeSeconds(path);
176
+ const text = thrashing(queue, agentId)
177
+ ? thrashAdvice(now)
178
+ : ask(now, path, age, settings.freshStateMinutes);
179
+
180
+ // Recorded only after it has reached stdout: a latch set on an ask that was never
181
+ // delivered would silence the next one for nothing.
182
+ emit(text);
183
+ recordAsk(sessionId, ledger, now.pct);
184
+ }
185
+
186
+ void main();
package/hooks/asks.mjs ADDED
@@ -0,0 +1,117 @@
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
+ const EMPTY = { askCount: 0, lastAskedAt: null, askedAtPct: null, deferredUntil: null, deferReason: null };
32
+
33
+ /**
34
+ * Keyed by Paseo agent id where there is one, because that survives compaction
35
+ * whatever Claude Code decides to do with the session id.
36
+ */
37
+ export function ledgerPath(sessionId) {
38
+ const agentId = process.env.PASEO_AGENT_ID;
39
+ const key = agentId !== undefined && agentId !== "" ? agentId : sessionId;
40
+ if (typeof key !== "string" || key === "") return null;
41
+ return join(pluginDir(), "asks", `${key.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
42
+ }
43
+
44
+ export function readLedger(sessionId) {
45
+ const path = ledgerPath(sessionId);
46
+ if (path === null) return { ...EMPTY };
47
+ try {
48
+ return { ...EMPTY, ...JSON.parse(readFileSync(path, "utf8")) };
49
+ } catch {
50
+ return { ...EMPTY };
51
+ }
52
+ }
53
+
54
+ export function writeLedger(sessionId, ledger) {
55
+ const path = ledgerPath(sessionId);
56
+ if (path === null) return;
57
+ try {
58
+ mkdirSync(join(pluginDir(), "asks"), { recursive: true });
59
+ const temp = `${path}.${randomUUID()}.tmp`;
60
+ writeFileSync(temp, JSON.stringify(ledger), "utf8");
61
+ renameSync(temp, path);
62
+ } catch {
63
+ // A hook that cannot persist its latch must still not break the turn.
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Forgets everything, because the window just emptied.
69
+ *
70
+ * A compaction is a new epoch: the occupancy the last ask was about is gone, and a
71
+ * session that fills up again deserves to be asked again.
72
+ */
73
+ export function resetLedger(sessionId) {
74
+ const path = ledgerPath(sessionId);
75
+ if (path === null) return;
76
+ try {
77
+ rmSync(path, { force: true });
78
+ } catch {
79
+ // A stale ledger costs one missed ask, never a failed turn.
80
+ }
81
+ }
82
+
83
+ /** The agent's own answer: not now, and here is for how long. */
84
+ export function defer(sessionId, minutes, reason) {
85
+ const ledger = readLedger(sessionId);
86
+ const until = new Date(Date.now() + minutes * 60_000).toISOString();
87
+ writeLedger(sessionId, { ...ledger, deferredUntil: until, deferReason: reason });
88
+ return until;
89
+ }
90
+
91
+ /**
92
+ * Whether to ask, given what has already been said and how much has changed.
93
+ *
94
+ * `pct` is the occupancy now; `askedAtPct` the occupancy the last ask was about.
95
+ */
96
+ export function shouldAsk(ledger, pct, now = Date.now()) {
97
+ if (ledger.deferredUntil !== null) {
98
+ const until = Date.parse(ledger.deferredUntil);
99
+ if (Number.isFinite(until) && now < until) return false;
100
+ }
101
+ if (ledger.askCount >= MAX_ASKS) return false;
102
+ if (ledger.askedAtPct === null) return true;
103
+ return pct >= ledger.askedAtPct + RE_ASK_GROWTH_PCT;
104
+ }
105
+
106
+ /** Records that the question was actually put to the model. */
107
+ export function recordAsk(sessionId, ledger, pct) {
108
+ writeLedger(sessionId, {
109
+ ...ledger,
110
+ askCount: ledger.askCount + 1,
111
+ lastAskedAt: new Date().toISOString(),
112
+ askedAtPct: pct,
113
+ // An answered ask clears the deferral it was waiting out.
114
+ deferredUntil: null,
115
+ deferReason: null,
116
+ });
117
+ }
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A PostToolUse hook that tells the agent how full it is — but only when that has
4
+ * just become worth saying.
5
+ *
6
+ * Sensing is useless if the agent has to remember to ask. This runs after every
7
+ * tool call, stays completely silent below the first threshold, and injects one
8
+ * short line the moment context crosses each band. Each level fires once per
9
+ * compaction epoch, so a long session gets three sentences, not three hundred.
10
+ *
11
+ * It describes; it never asks for anything. Deciding that a session should compact
12
+ * belongs at a turn boundary, and that is `ask-compact.mjs` on `Stop`.
13
+ *
14
+ * It reads the transcript rather than asking Paseo, so it works in any Claude Code
15
+ * session, and it reads only the tail of it, because this runs on the hot path.
16
+ */
17
+
18
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ import { BANDS, DEFAULT_THRESHOLDS, occupancy, readSettings } from "./context.mjs";
22
+ import { clearPending, pluginDir, readPending, statePath } from "./pointer.mjs";
23
+
24
+ function latchPathFor(sessionId) {
25
+ return join(pluginDir(), "thresholds", `${sessionId}.json`);
26
+ }
27
+
28
+ function readLatch(path) {
29
+ try {
30
+ return JSON.parse(readFileSync(path, "utf8"));
31
+ } catch {
32
+ return { fired: [], lastUsed: 0 };
33
+ }
34
+ }
35
+
36
+ function writeLatch(path, latch) {
37
+ try {
38
+ mkdirSync(dirname(path), { recursive: true });
39
+ writeFileSync(path, JSON.stringify(latch), "utf8");
40
+ } catch {
41
+ // A hook that cannot persist its latch must still not break the turn.
42
+ }
43
+ }
44
+
45
+ /**
46
+ * `band` chooses the urgency; `pct` and `used` describe the situation.
47
+ *
48
+ * On a large window the headline is the token count, not the percentage: "30% full"
49
+ * sounds like there is plenty of room, and 300,000 tokens does not.
50
+ */
51
+ function advice(band, pct, used, max, path) {
52
+ const large = max >= DEFAULT_THRESHOLDS.largeWindowFrom;
53
+ const lines = [
54
+ `Context holds ${used.toLocaleString()} tokens (${pct}% of a ${max.toLocaleString()}-token window).`,
55
+ ];
56
+
57
+ if (band === "compact") {
58
+ lines.push(
59
+ large
60
+ ? "That is a large enough context to be worth compacting now, even though the window is nowhere near full — what costs you is the prefix re-read on every turn, not the ceiling. Finish the step you are on and make sure your durable task state is on disk."
61
+ : "This is the point where recall degrades noticeably and every turn re-reads an expensive prefix. Finish the step you are on and write your durable task state to disk — do not start a new large step first.",
62
+ );
63
+ } else if (band === "closing") {
64
+ lines.push(
65
+ "Start closing out the current step rather than opening a new one. Make sure your durable task state — goal, current step and its next action, decisions and why, and every approach already tried and rejected — is written to disk, not only held in this conversation.",
66
+ );
67
+ } else {
68
+ lines.push(
69
+ "Nothing is wrong yet. This is the moment to make sure your task state is on disk, so that compacting later is cheap rather than lossy.",
70
+ );
71
+ }
72
+
73
+ if (path !== null) lines.push(`Your state file for this task: ${path}`);
74
+ else lines.push("If you have not chosen a state file for this task yet, choose one now and write to it.");
75
+ return lines.join(" ");
76
+ }
77
+
78
+ async function main() {
79
+ const raw = await new Promise((resolve) => {
80
+ let input = "";
81
+ process.stdin.setEncoding("utf8");
82
+ process.stdin.on("data", (chunk) => (input += chunk));
83
+ process.stdin.on("end", () => resolve(input));
84
+ });
85
+
86
+ let event;
87
+ try {
88
+ event = JSON.parse(raw);
89
+ } catch {
90
+ return; // Not our business to complain about a malformed event.
91
+ }
92
+
93
+ // Subagents have their own short-lived windows and their own stop conditions;
94
+ // warning them about the main thread's occupancy would be noise.
95
+ if (event.agent_id !== undefined && event.agent_id !== null) return;
96
+ const transcriptPath = event.transcript_path;
97
+ const sessionId = event.session_id;
98
+ if (typeof transcriptPath !== "string" || typeof sessionId !== "string") return;
99
+
100
+ // A compaction may have left a state-file pointer that no other hook could
101
+ // deliver. Said first, because it changes what the agent thinks it knows.
102
+ const parts = [];
103
+ const pending = readPending(sessionId);
104
+ if (pending !== null) parts.push(pending);
105
+
106
+ const now = occupancy(transcriptPath, readSettings());
107
+ if (now === null) return emit(sessionId, parts, pending);
108
+
109
+ const latchPath = latchPathFor(sessionId);
110
+ const latch = readLatch(latchPath);
111
+
112
+ // A compaction resets the epoch: the window emptied, so the warnings should be
113
+ // available again. Detected as a large drop rather than any drop, because usage
114
+ // wobbles by a few tokens between turns.
115
+ if (latch.lastUsed > 0 && now.used < latch.lastUsed * 0.6) latch.fired = [];
116
+ latch.lastUsed = now.used;
117
+
118
+ const reached = BANDS.filter((band) => now.pct >= now.profile[band]);
119
+ const crossed = reached.filter((band) => !latch.fired.includes(band));
120
+ if (crossed.length === 0) {
121
+ writeLatch(latchPath, latch);
122
+ return emit(sessionId, parts, pending);
123
+ }
124
+
125
+ // A session that jumped two bands in one turn hears the more urgent one.
126
+ const band = crossed[crossed.length - 1];
127
+ latch.fired = [...new Set([...latch.fired, ...crossed])];
128
+ writeLatch(latchPath, latch);
129
+
130
+ parts.push(advice(band, now.pct, now.used, now.max, statePath()));
131
+ return emit(sessionId, parts, pending);
132
+ }
133
+
134
+ /**
135
+ * Writes whatever there is to say, and only then forgets the pending pointer:
136
+ * clearing it before it reaches stdout would lose it on any later failure.
137
+ */
138
+ function emit(sessionId, parts, pending) {
139
+ if (parts.length === 0) return;
140
+ process.stdout.write(
141
+ `${JSON.stringify({
142
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: parts.join("\n\n") },
143
+ suppressOutput: true,
144
+ })}\n`,
145
+ );
146
+ if (pending !== null) clearPending(sessionId);
147
+ }
148
+
149
+ void main();
@@ -0,0 +1,233 @@
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 },
33
+ small: { notice: 60, closing: 75, compact: 85 },
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: { ...DEFAULT_THRESHOLDS.large, ...(merged.large ?? {}) },
64
+ small: { ...DEFAULT_THRESHOLDS.small, ...(merged.small ?? {}) },
65
+ },
66
+ };
67
+ } catch {
68
+ return DEFAULT_SETTINGS;
69
+ }
70
+ }
71
+
72
+ export function profileFor(maxTokens, thresholds) {
73
+ return maxTokens >= thresholds.largeWindowFrom ? thresholds.large : thresholds.small;
74
+ }
75
+
76
+ /**
77
+ * Whether this agent is one the governor may speak to.
78
+ *
79
+ * The same rule settings.server.ts resolves, duplicated here because a hook on the
80
+ * hot path must not open a daemon connection to answer it: an explicit answer in
81
+ * `enrolment.json` outranks the state-file inference in both directions, and the
82
+ * inference itself only counts while `autoEnrol` is on.
83
+ */
84
+ export function isEnrolled(agentId, settings = readSettings()) {
85
+ if (typeof agentId !== "string" || agentId === "") return false;
86
+ try {
87
+ const overrides = JSON.parse(readFileSync(join(pluginDir(), "enrolment.json"), "utf8"));
88
+ if (typeof overrides?.[agentId] === "boolean") return overrides[agentId];
89
+ } catch {
90
+ // No file, or one we cannot read: nobody has overridden anything.
91
+ }
92
+ if (!settings.autoEnrol) return false;
93
+ try {
94
+ return readdirSync(join(pluginDir(), "state")).includes(`${agentId}.md`);
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /** The tail of a file as text, or null if it cannot be read. */
101
+ function tail(path, bytes) {
102
+ let fd;
103
+ try {
104
+ fd = openSync(path, "r");
105
+ } catch {
106
+ return null;
107
+ }
108
+ try {
109
+ const size = fstatSync(fd).size;
110
+ const length = Math.min(size, bytes);
111
+ const buffer = Buffer.allocUnsafe(length);
112
+ readSync(fd, buffer, 0, length, size - length);
113
+ return buffer.toString("utf8");
114
+ } catch {
115
+ return null;
116
+ } finally {
117
+ closeSync(fd);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Context occupancy from the last assistant message's usage.
123
+ *
124
+ * input + cache_read + cache_creation + output is the same arithmetic Claude Code
125
+ * itself documents for a resumed session's `context_tokens`, so it is the
126
+ * sanctioned way to ask "how much of the window is currently occupied".
127
+ */
128
+ export function readContextTokens(transcriptPath) {
129
+ const text = tail(transcriptPath, TAIL_BYTES);
130
+ if (text === null) return null;
131
+ const lines = text.split("\n");
132
+
133
+ // Backwards: the newest assistant message wins, and a partial first line from
134
+ // slicing mid-file simply fails to parse and is skipped.
135
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
136
+ const line = lines[index];
137
+ if (line === undefined || line === "" || !line.includes('"usage"')) continue;
138
+ let entry;
139
+ try {
140
+ entry = JSON.parse(line);
141
+ } catch {
142
+ continue;
143
+ }
144
+ if (entry?.type !== "assistant") continue;
145
+ // A subagent's usage is its own window, not the main thread's.
146
+ if (entry.isSidechain === true) continue;
147
+ const usage = entry.message?.usage;
148
+ if (usage === undefined) continue;
149
+ const used =
150
+ (usage.input_tokens ?? 0) +
151
+ (usage.cache_read_input_tokens ?? 0) +
152
+ (usage.cache_creation_input_tokens ?? 0) +
153
+ (usage.output_tokens ?? 0);
154
+ return { used, model: entry.message?.model ?? null };
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Models whose context window is not the current default of 1M.
161
+ *
162
+ * The transcript records the resolved model id (`claude-opus-5`), never the config
163
+ * alias (`opus[1m]`), so the suffix is not a signal. Every current Opus, Sonnet and
164
+ * Fable model has a 1M window; Haiku and the older generations do not.
165
+ */
166
+ const SMALL_WINDOW = /haiku|-3-|sonnet-3|opus-3|sonnet-4-5|opus-4-5|sonnet-4-20|opus-4-20/;
167
+
168
+ /**
169
+ * The window size Paseo reported for this agent, from the recorder's own log.
170
+ *
171
+ * Read from the tail, like the transcript: this is the hot path, and the newest row
172
+ * is the only one that matters.
173
+ */
174
+ function recordedWindow() {
175
+ const agentId = process.env.PASEO_AGENT_ID;
176
+ if (agentId === undefined || agentId === "") return null;
177
+ const month = new Date().toISOString().slice(0, 7);
178
+ const text = tail(join(pluginDir(), `context-${month}.jsonl`), 64 * 1024);
179
+ if (text === null) return null;
180
+
181
+ const lines = text.split("\n");
182
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
183
+ const line = lines[index];
184
+ if (line === undefined || !line.includes(agentId)) continue;
185
+ try {
186
+ const row = JSON.parse(line);
187
+ if (row.agentId === agentId && typeof row.maxTokens === "number" && row.maxTokens > 0) {
188
+ return row.maxTokens;
189
+ }
190
+ } catch {
191
+ continue;
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * The window size for this session.
199
+ *
200
+ * Getting this wrong is worse than not warning at all: assuming 200k on a 1M
201
+ * session warns at a fifth of the real occupancy and reports negative headroom.
202
+ * So the first choice is what Paseo actually measured for this agent — recorded by
203
+ * the plugin, read from the tail of its log — and the model table is the fallback.
204
+ */
205
+ export function windowFor(model) {
206
+ const override = Number(process.env.SMART_SESSION_CONTEXT_WINDOW ?? "");
207
+ if (Number.isFinite(override) && override > 0) return override;
208
+
209
+ const recorded = recordedWindow();
210
+ if (recorded !== null) return recorded;
211
+
212
+ if (typeof model === "string" && SMALL_WINDOW.test(model)) return 200_000;
213
+ return 1_000_000;
214
+ }
215
+
216
+ /**
217
+ * Occupancy, the window it is measured against, and the bands that apply.
218
+ *
219
+ * Returns null when the transcript says nothing yet — a session with no completed
220
+ * assistant turn has no usage to read, and guessing would be worse than silence.
221
+ */
222
+ export function occupancy(transcriptPath, settings = readSettings()) {
223
+ const reading = readContextTokens(transcriptPath);
224
+ if (reading === null) return null;
225
+ const max = windowFor(reading.model);
226
+ return {
227
+ used: reading.used,
228
+ max,
229
+ model: reading.model,
230
+ pct: Math.floor((reading.used / max) * 100),
231
+ profile: profileFor(max, settings.thresholds),
232
+ };
233
+ }