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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/client/charts.tsx +185 -0
- package/client/pill.tsx +340 -0
- package/client/surface.tsx +574 -0
- package/docs/screenshots/smart-compact-handoff.png +0 -0
- package/hooks/ask-compact.mjs +186 -0
- package/hooks/asks.mjs +117 -0
- package/hooks/context-threshold.mjs +149 -0
- package/hooks/context.mjs +233 -0
- package/hooks/pointer.mjs +149 -0
- package/hooks/post-compact.mjs +135 -0
- package/index.client.tsx +42 -0
- package/index.server.ts +195 -0
- package/mcp.mjs +602 -0
- package/package.json +46 -0
- package/paseo-plugin.json +6 -0
- package/server/backfill.ts +328 -0
- package/server/daemon-password.d.mts +6 -0
- package/server/daemon-password.mjs +35 -0
- package/server/daemon.ts +198 -0
- package/server/governor.ts +288 -0
- package/server/growth.ts +67 -0
- package/server/install-on-load.ts +34 -0
- package/server/install.ts +318 -0
- package/server/recorder.ts +255 -0
- package/server/settings.ts +225 -0
- package/server/spend.ts +137 -0
- package/server/store.ts +201 -0
- package/server/upstream.ts +117 -0
- package/shared/blocks.ts +208 -0
- package/shared/format.ts +82 -0
- package/shared/governor.ts +178 -0
- package/shared/lifecycle.ts +17 -0
- package/shared/smart-session.ts +155 -0
- package/shared/thresholds.ts +115 -0
- package/shared/usage.ts +400 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconstructing token spend from Claude Code's own transcripts.
|
|
3
|
+
*
|
|
4
|
+
* The recorder can only know what it has watched. Everything before it was
|
|
5
|
+
* installed — and everything that happens while the daemon is down — is still
|
|
6
|
+
* recoverable in token terms, because every assistant message in
|
|
7
|
+
* `~/.claude/projects/**\/*.jsonl` carries its own usage. That gives the attribution
|
|
8
|
+
* the utilization percentages cannot: which model, which project, and main thread
|
|
9
|
+
* versus subagent.
|
|
10
|
+
*
|
|
11
|
+
* Transcripts are append-only, so the scan is incremental: each file is re-read only
|
|
12
|
+
* from where the last scan stopped.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { mkdir, open, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { basename, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { dataDir } from "./store.ts";
|
|
21
|
+
|
|
22
|
+
/** One hour of spend, split the ways that let you act on it. */
|
|
23
|
+
export interface SpendBucket {
|
|
24
|
+
/** ISO hour, e.g. "2026-09-05T12". */
|
|
25
|
+
readonly hour: string;
|
|
26
|
+
readonly model: string;
|
|
27
|
+
readonly project: string;
|
|
28
|
+
/** Subagent work, which is where a fan-out's cost actually lands. */
|
|
29
|
+
readonly sidechain: boolean;
|
|
30
|
+
input: number;
|
|
31
|
+
cacheRead: number;
|
|
32
|
+
cacheWrite: number;
|
|
33
|
+
output: number;
|
|
34
|
+
thinking: number;
|
|
35
|
+
messages: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Index {
|
|
39
|
+
version: 1;
|
|
40
|
+
/** Per transcript: how far we have read, so a re-scan reads only what is new. */
|
|
41
|
+
files: Record<string, { offset: number; size: number }>;
|
|
42
|
+
buckets: Record<string, SpendBucket>;
|
|
43
|
+
/** Request ids already counted, so a forked or resumed session is not double-counted. */
|
|
44
|
+
seen: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMPTY: Index = { version: 1, files: {}, buckets: {}, seen: [] };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How many request ids to remember.
|
|
51
|
+
*
|
|
52
|
+
* A resumed or forked session replays earlier lines verbatim, so deduplication has
|
|
53
|
+
* to be by request id rather than by position. Keeping every id ever seen would grow
|
|
54
|
+
* without bound; keeping the most recent slice covers replays, which are always of
|
|
55
|
+
* recent history.
|
|
56
|
+
*/
|
|
57
|
+
const SEEN_LIMIT = 200_000;
|
|
58
|
+
const MAX_TRANSCRIPT_READ_BYTES = 16 * 1024 * 1024;
|
|
59
|
+
|
|
60
|
+
function projectsRoot(): string {
|
|
61
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR;
|
|
62
|
+
return join(configDir !== undefined && configDir !== "" ? configDir : join(homedir(), ".claude"), "projects");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where subagent transcripts live: `/tmp/claude-<uid>/<project>/<session>/tasks/<agent>.output`.
|
|
67
|
+
*
|
|
68
|
+
* They are not in `~/.claude/projects` at all, which means any accounting that reads
|
|
69
|
+
* only that directory — the usual approach — misses subagent spend entirely. In a
|
|
70
|
+
* fan-out-heavy workflow that is not a rounding error; it is most of the bill.
|
|
71
|
+
*
|
|
72
|
+
* They live in the temp directory, so they are purged periodically. Whatever is
|
|
73
|
+
* there is worth counting; what has already gone is unrecoverable, and the recorder
|
|
74
|
+
* exists so that stops mattering.
|
|
75
|
+
*/
|
|
76
|
+
function tasksRoot(): string | null {
|
|
77
|
+
const override = process.env.SMART_SESSION_TASKS_ROOT;
|
|
78
|
+
if (override !== undefined && override !== "") return override;
|
|
79
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
80
|
+
return uid === null ? null : join("/tmp", `claude-${uid}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every transcript worth reading, with the project directory it belongs to. */
|
|
84
|
+
async function collectTranscripts(): Promise<{ path: string; directory: string }[]> {
|
|
85
|
+
const found: { path: string; directory: string }[] = [];
|
|
86
|
+
|
|
87
|
+
const root = projectsRoot();
|
|
88
|
+
try {
|
|
89
|
+
for (const directory of await readdir(root)) {
|
|
90
|
+
let names: string[];
|
|
91
|
+
try {
|
|
92
|
+
names = await readdir(join(root, directory));
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
if (name.endsWith(".jsonl")) found.push({ path: join(root, directory, name), directory });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// No transcripts on this machine.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const tasks = tasksRoot();
|
|
105
|
+
if (tasks !== null) {
|
|
106
|
+
try {
|
|
107
|
+
for (const directory of await readdir(tasks)) {
|
|
108
|
+
let sessions: string[];
|
|
109
|
+
try {
|
|
110
|
+
sessions = await readdir(join(tasks, directory));
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
for (const session of sessions) {
|
|
115
|
+
const taskDir = join(tasks, directory, session, "tasks");
|
|
116
|
+
let names: string[];
|
|
117
|
+
try {
|
|
118
|
+
names = await readdir(taskDir);
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (const name of names) {
|
|
123
|
+
if (name.endsWith(".output")) found.push({ path: join(taskDir, name), directory });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// No subagent transcripts, or the temp directory has been cleared.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const indexPath = () => join(dataDir(), "spend-index.json");
|
|
136
|
+
|
|
137
|
+
async function readIndex(): Promise<Index> {
|
|
138
|
+
try {
|
|
139
|
+
const raw = JSON.parse(await readFile(indexPath(), "utf8")) as Index;
|
|
140
|
+
if (raw.version !== 1) return { ...EMPTY };
|
|
141
|
+
return { version: 1, files: raw.files ?? {}, buckets: raw.buckets ?? {}, seen: raw.seen ?? [] };
|
|
142
|
+
} catch {
|
|
143
|
+
return { ...EMPTY };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function writeIndex(index: Index): Promise<void> {
|
|
148
|
+
await mkdir(dataDir(), { recursive: true });
|
|
149
|
+
const target = indexPath();
|
|
150
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
151
|
+
await writeFile(temp, JSON.stringify(index), "utf8");
|
|
152
|
+
await rename(temp, target);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The project a transcript belongs to.
|
|
157
|
+
*
|
|
158
|
+
* Directory names are the cwd with every non-alphanumeric character replaced, so the
|
|
159
|
+
* original path is not recoverable — but the tail is still the most useful label a
|
|
160
|
+
* person can read, and worktree names carry it.
|
|
161
|
+
*/
|
|
162
|
+
function projectLabel(dirName: string, cwd: string | null): string {
|
|
163
|
+
if (cwd !== null && cwd !== "") return basename(cwd);
|
|
164
|
+
const parts = dirName.split("-").filter((part) => part !== "");
|
|
165
|
+
return parts.slice(-2).join("-") || dirName;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function bucketKey(bucket: Omit<SpendBucket, keyof Totals>): string {
|
|
169
|
+
return `${bucket.hour}|${bucket.model}|${bucket.project}|${bucket.sidechain ? "sub" : "main"}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type Totals = { input: number; cacheRead: number; cacheWrite: number; output: number; thinking: number; messages: number };
|
|
173
|
+
|
|
174
|
+
/** Reads one transcript from `offset`, returning complete lines only. */
|
|
175
|
+
async function readFrom(
|
|
176
|
+
path: string,
|
|
177
|
+
offset: number,
|
|
178
|
+
size: number,
|
|
179
|
+
): Promise<{ text: string; end: number; truncated: boolean }> {
|
|
180
|
+
if (size <= offset) return { text: "", end: offset, truncated: false };
|
|
181
|
+
const handle = await open(path, "r");
|
|
182
|
+
try {
|
|
183
|
+
const end = Math.min(size, offset + MAX_TRANSCRIPT_READ_BYTES);
|
|
184
|
+
const length = end - offset;
|
|
185
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
186
|
+
await handle.read(buffer, 0, length, offset);
|
|
187
|
+
return { text: buffer.toString("utf8"), end, truncated: end < size };
|
|
188
|
+
} finally {
|
|
189
|
+
await handle.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface ScanResult {
|
|
194
|
+
readonly filesScanned: number;
|
|
195
|
+
readonly bytesRead: number;
|
|
196
|
+
readonly messagesCounted: number;
|
|
197
|
+
readonly buckets: SpendBucket[];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Scans every transcript for spend not yet counted.
|
|
202
|
+
*
|
|
203
|
+
* Safe to call repeatedly: only bytes appended since the last call are read, and a
|
|
204
|
+
* file that shrank (a rewrite, or a machine that lost the file) is read from the
|
|
205
|
+
* start again rather than trusted.
|
|
206
|
+
*/
|
|
207
|
+
export async function scanSpend(): Promise<ScanResult> {
|
|
208
|
+
const index = await readIndex();
|
|
209
|
+
const seen = new Set(index.seen);
|
|
210
|
+
|
|
211
|
+
let filesScanned = 0;
|
|
212
|
+
let bytesRead = 0;
|
|
213
|
+
let messagesCounted = 0;
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
for (const { path, directory } of await collectTranscripts()) {
|
|
217
|
+
let size: number;
|
|
218
|
+
try {
|
|
219
|
+
size = (await stat(path)).size;
|
|
220
|
+
} catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const previous = index.files[path] ?? { offset: 0, size: 0 };
|
|
225
|
+
// A file smaller than last time was replaced, not appended to.
|
|
226
|
+
const offset = size < previous.size ? 0 : previous.offset;
|
|
227
|
+
if (size === offset) continue;
|
|
228
|
+
|
|
229
|
+
let read: Awaited<ReturnType<typeof readFrom>>;
|
|
230
|
+
try {
|
|
231
|
+
read = await readFrom(path, offset, size);
|
|
232
|
+
} catch {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const chunk = read.text;
|
|
236
|
+
filesScanned += 1;
|
|
237
|
+
bytesRead += chunk.length;
|
|
238
|
+
|
|
239
|
+
// The final line may be half-written; leave it for the next scan.
|
|
240
|
+
const lastNewline = chunk.lastIndexOf("\n");
|
|
241
|
+
const complete = lastNewline === -1 ? "" : chunk.slice(0, lastNewline);
|
|
242
|
+
if (lastNewline === -1 && read.truncated) {
|
|
243
|
+
console.error(
|
|
244
|
+
`[smart-session] skipped ${read.end - offset} bytes from an overlong transcript line`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
index.files[path] = {
|
|
248
|
+
offset: lastNewline === -1 ? (read.truncated ? read.end : offset) : offset + lastNewline + 1,
|
|
249
|
+
size,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
for (const line of complete.split("\n")) {
|
|
253
|
+
if (line === "" || !line.includes('"usage"')) continue;
|
|
254
|
+
let entry: {
|
|
255
|
+
type?: string;
|
|
256
|
+
timestamp?: string;
|
|
257
|
+
requestId?: string;
|
|
258
|
+
cwd?: string;
|
|
259
|
+
isSidechain?: boolean;
|
|
260
|
+
message?: { model?: string; usage?: Record<string, unknown> };
|
|
261
|
+
};
|
|
262
|
+
try {
|
|
263
|
+
entry = JSON.parse(line);
|
|
264
|
+
} catch {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (entry.type !== "assistant") continue;
|
|
268
|
+
const usage = entry.message?.usage;
|
|
269
|
+
if (usage === undefined || typeof entry.timestamp !== "string") continue;
|
|
270
|
+
|
|
271
|
+
// One API request can produce several transcript lines (one per content
|
|
272
|
+
// block); its usage belongs to the request, not to each line.
|
|
273
|
+
const requestId = entry.requestId;
|
|
274
|
+
if (typeof requestId === "string") {
|
|
275
|
+
if (seen.has(requestId)) continue;
|
|
276
|
+
seen.add(requestId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const hour = entry.timestamp.slice(0, 13);
|
|
280
|
+
const key = bucketKey({
|
|
281
|
+
hour,
|
|
282
|
+
model: entry.message?.model ?? "unknown",
|
|
283
|
+
project: projectLabel(directory, entry.cwd ?? null),
|
|
284
|
+
sidechain: entry.isSidechain === true,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const bucket =
|
|
288
|
+
index.buckets[key] ??
|
|
289
|
+
(index.buckets[key] = {
|
|
290
|
+
hour,
|
|
291
|
+
model: entry.message?.model ?? "unknown",
|
|
292
|
+
project: projectLabel(directory, entry.cwd ?? null),
|
|
293
|
+
sidechain: entry.isSidechain === true,
|
|
294
|
+
input: 0,
|
|
295
|
+
cacheRead: 0,
|
|
296
|
+
cacheWrite: 0,
|
|
297
|
+
output: 0,
|
|
298
|
+
thinking: 0,
|
|
299
|
+
messages: 0,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const details = usage.output_tokens_details as { thinking_tokens?: number } | undefined;
|
|
303
|
+
bucket.input += Number(usage.input_tokens ?? 0);
|
|
304
|
+
bucket.cacheRead += Number(usage.cache_read_input_tokens ?? 0);
|
|
305
|
+
bucket.cacheWrite += Number(usage.cache_creation_input_tokens ?? 0);
|
|
306
|
+
bucket.output += Number(usage.output_tokens ?? 0);
|
|
307
|
+
bucket.thinking += Number(details?.thinking_tokens ?? 0);
|
|
308
|
+
bucket.messages += 1;
|
|
309
|
+
messagesCounted += 1;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Keep the most recent ids; replays are always of recent history.
|
|
315
|
+
index.seen = seen.size > SEEN_LIMIT ? [...seen].slice(-SEEN_LIMIT) : [...seen];
|
|
316
|
+
await writeIndex(index);
|
|
317
|
+
|
|
318
|
+
return { filesScanned, bytesRead, messagesCounted, buckets: Object.values(index.buckets) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The buckets already computed, without touching the filesystem beyond the index. */
|
|
322
|
+
export async function readSpend(sinceMs: number | null = null): Promise<SpendBucket[]> {
|
|
323
|
+
const index = await readIndex();
|
|
324
|
+
const buckets = Object.values(index.buckets);
|
|
325
|
+
if (sinceMs === null) return buckets;
|
|
326
|
+
const floor = new Date(sinceMs).toISOString().slice(0, 13);
|
|
327
|
+
return buckets.filter((bucket) => bucket.hour >= floor);
|
|
328
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the daemon's plaintext password without reading the bcrypt hash in
|
|
7
|
+
* config.json or putting a secret into an error. The standard Paseo environment
|
|
8
|
+
* wins, then an explicitly configured file, then the paseo-hub VM secret.
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveDaemonPassword(options = {}) {
|
|
11
|
+
const env = options.env ?? process.env;
|
|
12
|
+
const fromEnv = env.PASEO_PASSWORD?.trim();
|
|
13
|
+
if (fromEnv) return fromEnv;
|
|
14
|
+
|
|
15
|
+
const configuredFile = env.PASEO_PASSWORD_FILE?.trim();
|
|
16
|
+
if (configuredFile) return readPasswordFile(configuredFile, true);
|
|
17
|
+
|
|
18
|
+
const home = options.home ?? homedir();
|
|
19
|
+
return readPasswordFile(join(home, "paseo-hub", "secrets", "daemon-password"), false);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readPasswordFile(path, required) {
|
|
23
|
+
try {
|
|
24
|
+
const password = (await readFile(path, "utf8")).trim();
|
|
25
|
+
if (password !== "") return password;
|
|
26
|
+
if (required) throw new Error("PASEO_PASSWORD_FILE is empty.");
|
|
27
|
+
return undefined;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!required) return undefined;
|
|
30
|
+
if (error instanceof Error && error.message === "PASEO_PASSWORD_FILE is empty.") throw error;
|
|
31
|
+
// Do not echo the filesystem error: it can include secret-bearing paths or
|
|
32
|
+
// platform details and is not useful to an agent that cannot repair them.
|
|
33
|
+
throw new Error("PASEO_PASSWORD_FILE could not be read.");
|
|
34
|
+
}
|
|
35
|
+
}
|
package/server/daemon.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived connections to Paseo's own daemon.
|
|
3
|
+
*
|
|
4
|
+
* The plugin SDK exposes neither provider usage nor per-agent context usage, so
|
|
5
|
+
* this borrows the host's daemon client the way paseo-defer does. Each call opens
|
|
6
|
+
* and closes its own connection: a long-lived socket in this subprocess keeps the
|
|
7
|
+
* event loop alive and hangs Paseo's "Stopping plugin" step, which wedges reload
|
|
8
|
+
* for the life of the daemon.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { resolveDaemonPassword } from "./daemon-password.mjs";
|
|
16
|
+
|
|
17
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
18
|
+
|
|
19
|
+
export interface DaemonClient {
|
|
20
|
+
connect(): Promise<void>;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
fetchAgents(): Promise<unknown>;
|
|
23
|
+
listProviderUsage(): Promise<unknown>;
|
|
24
|
+
sendMessage(agentId: string, text: string): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DaemonClientModule {
|
|
28
|
+
DaemonClient: new (options: {
|
|
29
|
+
url: string;
|
|
30
|
+
clientId: string;
|
|
31
|
+
clientType: string;
|
|
32
|
+
reconnect: { enabled: boolean };
|
|
33
|
+
connectTimeoutMs: number;
|
|
34
|
+
suppressSendErrors: boolean;
|
|
35
|
+
password?: string;
|
|
36
|
+
}) => DaemonClient;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let daemonClientModule: DaemonClientModule | null = null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The specifier is assembled rather than written as a literal so the plugin
|
|
43
|
+
* compiler cannot resolve it at build time: `paseo plugin add` installs from Git
|
|
44
|
+
* and runs no package manager, so a statically imported dependency fails to
|
|
45
|
+
* compile. Resolving through the subprocess's own `require` also guarantees the
|
|
46
|
+
* client matches the daemon's protocol version exactly.
|
|
47
|
+
*/
|
|
48
|
+
function loadDaemonClientModule(): DaemonClientModule {
|
|
49
|
+
if (daemonClientModule !== null) return daemonClientModule;
|
|
50
|
+
const specifier = ["@getpaseo", "client", "internal", "daemon-client"].join("/");
|
|
51
|
+
try {
|
|
52
|
+
daemonClientModule = require(specifier) as DaemonClientModule;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`This Paseo host does not expose its daemon client (${specifier}), which Smart Session needs to read plan usage and agent context: ${
|
|
56
|
+
error instanceof Error ? error.message : String(error)
|
|
57
|
+
}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return daemonClientModule;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let cachedUrl: string | null = null;
|
|
64
|
+
|
|
65
|
+
async function resolveUrl(): Promise<string> {
|
|
66
|
+
if (cachedUrl !== null) return cachedUrl;
|
|
67
|
+
const fromEnv = process.env.PASEO_DAEMON_URL;
|
|
68
|
+
if (fromEnv !== undefined && fromEnv !== "") {
|
|
69
|
+
cachedUrl = fromEnv;
|
|
70
|
+
return cachedUrl;
|
|
71
|
+
}
|
|
72
|
+
const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
73
|
+
let listen: string | undefined;
|
|
74
|
+
try {
|
|
75
|
+
const raw = JSON.parse(await readFile(join(home, "config.json"), "utf8")) as {
|
|
76
|
+
daemon?: { listen?: string };
|
|
77
|
+
};
|
|
78
|
+
listen = raw.daemon?.listen;
|
|
79
|
+
} catch {
|
|
80
|
+
// Fall through to the documented default.
|
|
81
|
+
}
|
|
82
|
+
cachedUrl = `ws://${listen ?? "127.0.0.1:6767"}/ws`;
|
|
83
|
+
return cachedUrl;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Runs `work` against a connection that is always closed before returning. */
|
|
87
|
+
export async function withDaemon<T>(work: (client: DaemonClient) => Promise<T>): Promise<T> {
|
|
88
|
+
const { DaemonClient } = loadDaemonClientModule();
|
|
89
|
+
const password = await resolveDaemonPassword();
|
|
90
|
+
const client = new DaemonClient({
|
|
91
|
+
url: await resolveUrl(),
|
|
92
|
+
clientId: "paseo-smart-session",
|
|
93
|
+
clientType: "cli",
|
|
94
|
+
reconnect: { enabled: false },
|
|
95
|
+
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
96
|
+
suppressSendErrors: true,
|
|
97
|
+
...(password === undefined ? {} : { password }),
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
await client.connect();
|
|
101
|
+
return await work(client);
|
|
102
|
+
} finally {
|
|
103
|
+
try {
|
|
104
|
+
await client.close();
|
|
105
|
+
} catch {
|
|
106
|
+
// Teardown must not mask the original result or error.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The raw `provider.usage.list` payload.
|
|
113
|
+
*
|
|
114
|
+
* Deliberately untyped past `unknown`: it goes straight into the normalizer in
|
|
115
|
+
* `shared/usage.ts`, which is the one place that knows how to read it, and which
|
|
116
|
+
* tolerates a daemon that renames a field.
|
|
117
|
+
*/
|
|
118
|
+
export async function readProviderUsage(client: DaemonClient): Promise<unknown> {
|
|
119
|
+
return (await client.listProviderUsage()) as unknown;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type AgentState = "initializing" | "idle" | "running" | "error" | "closed";
|
|
123
|
+
|
|
124
|
+
export interface AgentRow {
|
|
125
|
+
readonly id: string;
|
|
126
|
+
readonly title: string | null;
|
|
127
|
+
readonly provider: string;
|
|
128
|
+
readonly model: string | null;
|
|
129
|
+
readonly status: AgentState;
|
|
130
|
+
readonly cwd: string | null;
|
|
131
|
+
readonly workspaceId: string | null;
|
|
132
|
+
/** Context occupancy as of the agent's last turn; absent until it has run one. */
|
|
133
|
+
readonly usedTokens: number | null;
|
|
134
|
+
readonly maxTokens: number | null;
|
|
135
|
+
readonly costUsd: number | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Every agent the daemon knows, with its context-window occupancy.
|
|
140
|
+
*
|
|
141
|
+
* `lastUsage` rides on the agent *snapshot* (`entries[].agent`), not on the leaner
|
|
142
|
+
* list-item shape, which carries no usage at all.
|
|
143
|
+
*/
|
|
144
|
+
export async function readAgents(client: DaemonClient): Promise<AgentRow[]> {
|
|
145
|
+
const payload = (await client.fetchAgents()) as {
|
|
146
|
+
entries?: readonly {
|
|
147
|
+
agent?: {
|
|
148
|
+
id?: string;
|
|
149
|
+
title?: string | null;
|
|
150
|
+
provider?: string;
|
|
151
|
+
model?: string | null;
|
|
152
|
+
status?: string;
|
|
153
|
+
cwd?: string | null;
|
|
154
|
+
workspaceId?: string | null;
|
|
155
|
+
lastUsage?: {
|
|
156
|
+
contextWindowUsedTokens?: number;
|
|
157
|
+
contextWindowMaxTokens?: number;
|
|
158
|
+
totalCostUsd?: number;
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
}[];
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const rows: AgentRow[] = [];
|
|
165
|
+
for (const entry of payload.entries ?? []) {
|
|
166
|
+
const agent = entry.agent;
|
|
167
|
+
if (agent?.id === undefined) continue;
|
|
168
|
+
rows.push({
|
|
169
|
+
id: agent.id,
|
|
170
|
+
title: agent.title ?? null,
|
|
171
|
+
provider: agent.provider ?? "unknown",
|
|
172
|
+
model: agent.model ?? null,
|
|
173
|
+
status: (agent.status ?? "closed") as AgentState,
|
|
174
|
+
cwd: agent.cwd ?? null,
|
|
175
|
+
workspaceId: agent.workspaceId ?? null,
|
|
176
|
+
usedTokens: agent.lastUsage?.contextWindowUsedTokens ?? null,
|
|
177
|
+
maxTokens: agent.lastUsage?.contextWindowMaxTokens ?? null,
|
|
178
|
+
costUsd: agent.lastUsage?.totalCostUsd ?? null,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return rows;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Delivers text to an agent as a new message.
|
|
186
|
+
*
|
|
187
|
+
* A leading `/compact` is not a string the model reads: Paseo parses slash
|
|
188
|
+
* commands out of a prompt and honours the root-only set — `clear`, `compact`,
|
|
189
|
+
* `context`, `usage` and friends — so this is the supported route to compaction,
|
|
190
|
+
* and the reason an agent can be made to compact itself at all.
|
|
191
|
+
*/
|
|
192
|
+
export async function sendToAgent(
|
|
193
|
+
client: DaemonClient,
|
|
194
|
+
agentId: string,
|
|
195
|
+
text: string,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
await client.sendMessage(agentId, text);
|
|
198
|
+
}
|