paseo-smart-session 3.4.0 → 3.6.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 +7 -5
- package/client/charts.tsx +7 -6
- package/client/overview.tsx +13 -8
- package/client/pill-view.ts +6 -5
- package/client/pill.tsx +2 -2
- package/client/popover.tsx +10 -11
- package/client/settings.tsx +39 -17
- package/hooks/ask-compact.mjs +13 -119
- package/hooks/ask.mjs +198 -0
- package/hooks/asks.mjs +2 -117
- package/hooks/context-threshold.mjs +35 -11
- package/hooks/context.mjs +2 -233
- package/hooks/pointer.mjs +2 -149
- package/index.server.ts +32 -3
- package/mcp.mjs +49 -458
- package/package.json +3 -2
- package/server/agent-tools.d.mts +15 -0
- package/server/agent-tools.mjs +472 -0
- package/server/growth.ts +2 -1
- package/server/hooklib/asks.mjs +163 -0
- package/server/hooklib/context.mjs +284 -0
- package/server/hooklib/pointer.mjs +149 -0
- package/server/install-on-load.ts +15 -1
- package/server/mcp-registrations.ts +126 -0
- package/server/native-tools.ts +60 -0
- package/server/session-mcp.ts +54 -17
- package/shared/chat.ts +6 -2
- package/shared/compact-lines.ts +60 -0
- package/shared/forecast.ts +38 -12
- package/shared/smart-session.ts +6 -0
- package/shared/thresholds.ts +41 -6
|
@@ -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,8 @@
|
|
|
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 { install, resolvePluginDir } from "./install.ts";
|
|
17
|
+
import { removeGlobalRegistrations } from "./mcp-registrations.ts";
|
|
17
18
|
|
|
18
19
|
void install()
|
|
19
20
|
.then((report) => {
|
|
@@ -33,3 +34,16 @@ void install()
|
|
|
33
34
|
}
|
|
34
35
|
})
|
|
35
36
|
.catch((error: unknown) => console.error("[smart-session] hook install failed", String(error)));
|
|
37
|
+
|
|
38
|
+
// Older versions registered the agent tools globally; they are attached per launch now. Only an
|
|
39
|
+
// install Paseo actually loads from this PASEO_HOME does this, so a check that loads the bundle
|
|
40
|
+
// against a sandbox (check-teardown.mjs) never reaches the machine's real Claude and Codex homes.
|
|
41
|
+
void resolvePluginDir()
|
|
42
|
+
.then((directory) => (directory === null ? [] : removeGlobalRegistrations()))
|
|
43
|
+
.then((results) => {
|
|
44
|
+
for (const result of results) {
|
|
45
|
+
if (result.status === "removed") console.log(`[smart-session] removed the global MCP registration from ${result.path} (backup beside it)`);
|
|
46
|
+
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"}`);
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
.catch(() => undefined);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { chmod, copyFile, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Smart Session's agent tools are attached per launch (natively on Paseo 0.15+, or as a per-launch
|
|
10
|
+
* stdio server), so a user-scope "smart-session" MCP entry left by an older version only makes every
|
|
11
|
+
* Claude or Codex session on the machine, Paseo's or not, start its own `node mcp.mjs` — or fail to,
|
|
12
|
+
* when it points at an install directory a later update removed. This removes such entries from
|
|
13
|
+
* every Claude and Codex home Paseo launches agents with, backing each file up first.
|
|
14
|
+
*
|
|
15
|
+
* An entry is ours only when it runs this plugin's `mcp.mjs`. `.claude.json` holds credentials, so
|
|
16
|
+
* it is read but never written here: `claude mcp remove` does that, under Claude Code's own lock,
|
|
17
|
+
* with `CLAUDE_CONFIG_DIR` pointing at the home in question.
|
|
18
|
+
*/
|
|
19
|
+
export type Unregistered = { path: string; status: "removed" | "absent" | "foreign" | "unavailable" | "error"; error?: string };
|
|
20
|
+
const NAME = "smart-session";
|
|
21
|
+
const BACKUP = ".smart-session-unregister.bak";
|
|
22
|
+
const OURS = /(?:^|[\\/])(?:paseo-smart-session|smart-session)[\\/](?:.*[\\/])?mcp\.mjs$/;
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
/** Every Claude global config (.claude.json) and Codex config.toml Paseo may launch an agent with. */
|
|
26
|
+
export async function agentHomes(env: NodeJS.ProcessEnv = process.env, paseoHome = env.PASEO_HOME ?? join(homedir(), ".paseo"), home = homedir()): Promise<{ claude: string[]; codex: string[] }> {
|
|
27
|
+
const claudeDirs = new Set<string>([env.CLAUDE_CONFIG_DIR?.trim() || home, home]);
|
|
28
|
+
const codexDirs = new Set<string>([env.CODEX_HOME?.trim() || join(home, ".codex"), join(home, ".codex")]);
|
|
29
|
+
// Homes set in Paseo's provider settings (agents.providers.<id>.env).
|
|
30
|
+
try {
|
|
31
|
+
const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf8"));
|
|
32
|
+
for (const provider of Object.values(config?.agents?.providers ?? {}) as { env?: Record<string, string> }[]) {
|
|
33
|
+
if (provider?.env?.CLAUDE_CONFIG_DIR) claudeDirs.add(provider.env.CLAUDE_CONFIG_DIR);
|
|
34
|
+
if (provider?.env?.CODEX_HOME) codexDirs.add(provider.env.CODEX_HOME);
|
|
35
|
+
}
|
|
36
|
+
} catch { /* No config: the defaults. */ }
|
|
37
|
+
// Homes ZeroSub creates per account (~/.paseo/zerosub/homes/<id>/), Claude and Codex alike. ZeroSub
|
|
38
|
+
// seeds them from the main .claude.json, which is in the list above, so the entry does not come back.
|
|
39
|
+
const zerosub = join(paseoHome, "zerosub", "homes");
|
|
40
|
+
for (const entry of await readdir(zerosub, { withFileTypes: true }).catch(() => [])) {
|
|
41
|
+
if (!entry.isDirectory()) continue;
|
|
42
|
+
claudeDirs.add(join(zerosub, entry.name)); codexDirs.add(join(zerosub, entry.name));
|
|
43
|
+
}
|
|
44
|
+
const existing = async (paths: string[]) => [...new Set((await Promise.all(paths.map(async (p) => (await stat(p).catch(() => null))?.isFile() ? await realpath(p) : null))).filter((p): p is string => !!p))];
|
|
45
|
+
return {
|
|
46
|
+
claude: await existing([...claudeDirs].map((dir) => join(resolve(dir), ".claude.json"))),
|
|
47
|
+
codex: await existing([...codexDirs].map((dir) => join(resolve(dir), "config.toml"))),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ours = (server: unknown) => {
|
|
52
|
+
const entry = server as { command?: unknown; args?: unknown } | null;
|
|
53
|
+
return [entry?.command, ...(Array.isArray(entry?.args) ? entry.args : [])].some((part) => typeof part === "string" && OURS.test(part));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
async function backup(path: string): Promise<void> {
|
|
57
|
+
await copyFile(path, path + BACKUP, 1).catch((error) => { if (error.code !== "EEXIST") throw error; });
|
|
58
|
+
await chmod(path + BACKUP, (await stat(path)).mode & 0o777);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type RunClaude = (args: string[], env: NodeJS.ProcessEnv) => Promise<unknown>;
|
|
62
|
+
const runClaude: RunClaude = (args, env) => execFileAsync("claude", args, { env, timeout: 30_000, maxBuffer: 1024 * 1024 });
|
|
63
|
+
|
|
64
|
+
export async function unregisterClaude(path: string, run: RunClaude = runClaude, home = homedir()): Promise<Unregistered> {
|
|
65
|
+
try {
|
|
66
|
+
const entry = JSON.parse(await readFile(path, "utf8"))?.mcpServers?.[NAME];
|
|
67
|
+
if (!entry) return { path, status: "absent" };
|
|
68
|
+
if (!ours(entry)) return { path, status: "foreign" };
|
|
69
|
+
await backup(path);
|
|
70
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
71
|
+
// ~/.claude.json is the default home's; any other directory is a CLAUDE_CONFIG_DIR.
|
|
72
|
+
if (dirname(path) === await realpath(home).catch(() => resolve(home))) delete env.CLAUDE_CONFIG_DIR; else env.CLAUDE_CONFIG_DIR = dirname(path);
|
|
73
|
+
try { await run(["mcp", "remove", NAME, "--scope", "user"], env); } catch (error) {
|
|
74
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { path, status: "unavailable" };
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const after = JSON.parse(await readFile(path, "utf8"))?.mcpServers?.[NAME];
|
|
78
|
+
return after ? { path, status: "error", error: "still registered after claude mcp remove" } : { path, status: "removed" };
|
|
79
|
+
} catch (error) { return { path, status: "error", error: (error as Error).message }; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Removes [mcp_servers.smart-session] and its sub-tables when they run our mcp.mjs, keeping everything else verbatim. */
|
|
83
|
+
export function withoutCodexEntry(text: string): string | null {
|
|
84
|
+
const lines = text.split("\n");
|
|
85
|
+
const header = (line: string) => /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line)?.[1].replace(/["']/g, "") ?? null;
|
|
86
|
+
const mine = (name: string | null) => name === `mcp_servers.${NAME}` || name?.startsWith(`mcp_servers.${NAME}.`);
|
|
87
|
+
const tables: { start: number; end: number }[] = [];
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
if (!mine(header(lines[i]!))) continue;
|
|
90
|
+
let end = i + 1;
|
|
91
|
+
while (end < lines.length && header(lines[end]!) === null) end++;
|
|
92
|
+
// Comments and blank lines just above the next table belong to it.
|
|
93
|
+
while (end > i + 1 && /^\s*(#.*)?$/.test(lines[end - 1]!)) end--;
|
|
94
|
+
tables.push({ start: i, end }); i = end - 1;
|
|
95
|
+
}
|
|
96
|
+
if (!tables.length) return null;
|
|
97
|
+
const body = tables.map((t) => lines.slice(t.start, t.end).join("\n")).join("\n");
|
|
98
|
+
const strings = [...body.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]!);
|
|
99
|
+
if (!strings.some((value) => OURS.test(value))) return text;
|
|
100
|
+
const keep = lines.filter((_, i) => !tables.some((t) => i >= t.start && i < t.end));
|
|
101
|
+
return keep.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function unregisterCodex(path: string): Promise<Unregistered> {
|
|
105
|
+
try {
|
|
106
|
+
const text = await readFile(path, "utf8");
|
|
107
|
+
const next = withoutCodexEntry(text);
|
|
108
|
+
if (next === null) return { path, status: "absent" };
|
|
109
|
+
if (next === text) return { path, status: "foreign" };
|
|
110
|
+
await backup(path);
|
|
111
|
+
const temporary = `${path}.smart-session.${randomBytes(4).toString("hex")}.tmp`;
|
|
112
|
+
await writeFile(temporary, next, { mode: (await stat(path)).mode & 0o777 });
|
|
113
|
+
await rename(temporary, path);
|
|
114
|
+
return { path, status: "removed" };
|
|
115
|
+
} catch (error) { return { path, status: "error", error: (error as Error).message }; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Removes Smart Session's own global registrations from every known home. Never throws. */
|
|
119
|
+
export async function removeGlobalRegistrations(homes?: { claude: string[]; codex: string[] }, run: RunClaude = runClaude, home = homedir()): Promise<Unregistered[]> {
|
|
120
|
+
const found = homes ?? await agentHomes().catch(() => ({ claude: [], codex: [] }));
|
|
121
|
+
// One at a time: `claude mcp remove` is a whole CLI process, and they would share the main file's lock.
|
|
122
|
+
const results: Unregistered[] = [];
|
|
123
|
+
for (const path of found.claude) results.push(await unregisterClaude(path, run, home));
|
|
124
|
+
for (const path of found.codex) results.push(await unregisterCodex(path));
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AgentToolContext, AgentToolDefinition, PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
|
3
|
+
import { TOOL_SHAPES, agentTools, named } from "./agent-tools.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Smart Session's agent tools served natively (Paseo 0.15+). At `agent.session_open` they go on
|
|
7
|
+
* `request.tools`, and the daemon lists them on the agent's own Paseo MCP server and routes each
|
|
8
|
+
* call here over IPC. No process per agent, and the caller's id comes from the daemon's per-agent
|
|
9
|
+
* endpoint rather than an environment variable. `mcp.mjs` stays as the per-launch fallback where
|
|
10
|
+
* the host doesn't serve plugin tools (`nativeToolsAvailable`).
|
|
11
|
+
*
|
|
12
|
+
* Names carry a `smart_` prefix: plugin tools share one namespace with Paseo's own, and a
|
|
13
|
+
* collision fails the agent's whole Paseo tool list.
|
|
14
|
+
*/
|
|
15
|
+
export const PREFIX = "smart_";
|
|
16
|
+
|
|
17
|
+
type PaseoConfig = {
|
|
18
|
+
daemon?: { mcp?: { enabled?: boolean; injectIntoAgents?: boolean } };
|
|
19
|
+
agents?: { providers?: Record<string, { paseoTools?: { enabled?: boolean; disabledTools?: string[] } }> };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Whether this host serves plugin tools to this agent: `request.tools` exists, the daemon injects its MCP, and the provider's Paseo tools aren't off. */
|
|
23
|
+
export function nativeToolsAvailable(request: Pick<PluginSessionOpenRequest, "provider"> & { tools?: unknown }, config: PaseoConfig | null): boolean {
|
|
24
|
+
if (!Array.isArray(request.tools)) return false;
|
|
25
|
+
if (config?.daemon?.mcp?.enabled === false || config?.daemon?.mcp?.injectIntoAgents === false) return false;
|
|
26
|
+
const policy = config?.agents?.providers?.[request.provider]?.paseoTools;
|
|
27
|
+
if (policy?.enabled === false) return false;
|
|
28
|
+
return !policy?.disabledTools?.some((name) => name.startsWith(PREFIX));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Calls one of this plugin's RPCs in-process, as the stdio server would over the daemon. */
|
|
32
|
+
export type Invoke = (method: string, input: unknown, context: AgentToolContext) => Promise<unknown>;
|
|
33
|
+
|
|
34
|
+
const fromJSONSchema = (schema: unknown): z.ZodType =>
|
|
35
|
+
(z as unknown as { fromJSONSchema: (schema: unknown) => z.ZodType }).fromJSONSchema(schema);
|
|
36
|
+
|
|
37
|
+
/** The tool definitions for one session. The same text and rules as `mcp.mjs`, run in this process. */
|
|
38
|
+
export function nativeTools(invoke: Invoke): AgentToolDefinition[] {
|
|
39
|
+
// Built once per worker: the definitions hold no per-session state (the caller comes with each call).
|
|
40
|
+
const cached = built.get(invoke);
|
|
41
|
+
if (cached) return cached;
|
|
42
|
+
const tools = TOOL_SHAPES.map((shape) => ({
|
|
43
|
+
name: PREFIX + shape.name,
|
|
44
|
+
description: named(shape.description, true),
|
|
45
|
+
input: fromJSONSchema(shape.inputSchema),
|
|
46
|
+
async execute(input: unknown, context: AgentToolContext) {
|
|
47
|
+
try {
|
|
48
|
+
const tools = agentTools({ agentId: context.agent.id, call: (method, payload) => invoke(method, payload, context) });
|
|
49
|
+
const text = await tools[shape.name as keyof typeof tools].run((input ?? {}) as Record<string, unknown>);
|
|
50
|
+
return { text: named(text, true) };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// A tool result, not a protocol error, so the model reads it, as in mcp.mjs.
|
|
53
|
+
return { isError: true, text: `Failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
built.set(invoke, tools);
|
|
58
|
+
return tools;
|
|
59
|
+
}
|
|
60
|
+
const built = new WeakMap<Invoke, AgentToolDefinition[]>();
|