@phnx-labs/agents-cli 1.20.76 → 1.20.78
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/CHANGELOG.md +264 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +143 -9
- package/dist/commands/exec.js +87 -19
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +275 -15
- package/dist/commands/secrets.js +65 -17
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +128 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-picker.d.ts +28 -1
- package/dist/commands/sessions-picker.js +222 -15
- package/dist/commands/sessions.d.ts +117 -4
- package/dist/commands/sessions.js +350 -62
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +48 -3
- package/dist/index.js +5 -6
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/agents.d.ts +0 -21
- package/dist/lib/agents.js +0 -37
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +9 -32
- package/dist/lib/daemon.js +70 -84
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +33 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +156 -28
- package/dist/lib/sandbox.js +0 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +11 -4
- package/dist/lib/secrets/agent.js +46 -24
- package/dist/lib/secrets/bundles.d.ts +21 -7
- package/dist/lib/secrets/bundles.js +82 -38
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +16 -2
- package/dist/lib/secrets/session-store.js +118 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +17 -0
- package/dist/lib/session/active.js +105 -7
- package/dist/lib/session/db.d.ts +43 -0
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +28 -3
- package/dist/lib/session/discover.js +110 -14
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +94 -12
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Usage-based unlock hints: surface bundles you keep getting a Touch ID prompt
|
|
2
|
+
// for, so `agents secrets status` can suggest unlocking them once instead of
|
|
3
|
+
// prompting on every read. Built on the existing `secrets.get` audit events
|
|
4
|
+
// (events.jsonl) — no new tracking. Pure + exported so the threshold/filter
|
|
5
|
+
// logic is unit-testable without the events sink or the keychain.
|
|
6
|
+
/**
|
|
7
|
+
* From recent `secrets.get` records, find bundles read often enough via the
|
|
8
|
+
* keychain (i.e. NOT served silently by the broker or a durable session) to be
|
|
9
|
+
* worth unlocking once. A bundle currently `held` is already silent, so it is
|
|
10
|
+
* excluded; the caller further drops `never`/no-ACL bundles (which never prompt,
|
|
11
|
+
* so unlocking them is a no-op).
|
|
12
|
+
*
|
|
13
|
+
* @param records recent `secrets.get` events (any order)
|
|
14
|
+
* @param held bundle names the broker currently holds (silent reads)
|
|
15
|
+
* @param opts.minReads minimum prompting reads to surface a bundle (default 3)
|
|
16
|
+
*/
|
|
17
|
+
export function frequentlyPromptedBundles(records, held, opts = {}) {
|
|
18
|
+
const minReads = opts.minReads ?? 3;
|
|
19
|
+
const counts = new Map();
|
|
20
|
+
for (const r of records) {
|
|
21
|
+
if (!r.bundle)
|
|
22
|
+
continue;
|
|
23
|
+
// A broker hit or a durable-session read raised no Touch ID sheet — only a
|
|
24
|
+
// read that fell through to the keychain did. Count those; they are the ones
|
|
25
|
+
// an unlock would silence.
|
|
26
|
+
if (r.source === 'agent' || r.source === 'session')
|
|
27
|
+
continue;
|
|
28
|
+
if (held.has(r.bundle))
|
|
29
|
+
continue;
|
|
30
|
+
counts.set(r.bundle, (counts.get(r.bundle) ?? 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
return [...counts.entries()]
|
|
33
|
+
.filter(([, n]) => n >= minReads)
|
|
34
|
+
.map(([name, count]) => ({ name, count }))
|
|
35
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
36
|
+
}
|
|
@@ -4,6 +4,7 @@ import { type HookSessionIndex } from './hook-sessions.js';
|
|
|
4
4
|
import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
|
|
5
5
|
import type { SessionAttachment } from './types.js';
|
|
6
6
|
import { type SessionProvenance } from './provenance.js';
|
|
7
|
+
import { type DeviceRegistry } from '../devices/registry.js';
|
|
7
8
|
import { type Presence } from './detached.js';
|
|
8
9
|
/**
|
|
9
10
|
* Per-PID `lsof` probes run bounded and staggered rather than as one parallel
|
|
@@ -188,6 +189,13 @@ export declare function agentKindFromComm(commRaw: string): string | undefined;
|
|
|
188
189
|
* the start time can't be read, we keep the existence answer.
|
|
189
190
|
*/
|
|
190
191
|
export declare function isPidAlive(pid: number, startedAtMs?: number): boolean;
|
|
192
|
+
/**
|
|
193
|
+
* Resolve a Claude transcript for `cwd` across the given project roots, newest
|
|
194
|
+
* mtime winning when the same id/cwd resolves in more than one version home
|
|
195
|
+
* (the actively-written copy is the newest). Pure over `projectRoots`, so it is
|
|
196
|
+
* testable against temp dirs without touching the real home directory.
|
|
197
|
+
*/
|
|
198
|
+
export declare function pickClaudeSessionFileAcrossRoots(projectRoots: string[], cwd: string, sessionId?: string): string | undefined;
|
|
191
199
|
/**
|
|
192
200
|
* Pick a Claude transcript file within a project dir.
|
|
193
201
|
*
|
|
@@ -353,6 +361,15 @@ export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
|
|
|
353
361
|
* terminal/headless row for the same session id.
|
|
354
362
|
*/
|
|
355
363
|
export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
|
|
364
|
+
/**
|
|
365
|
+
* Match an SSH client IP to a registered device (pure — testable with a plain
|
|
366
|
+
* registry object). Returns the device name + ssh login user when the IP is a
|
|
367
|
+
* known device address.
|
|
368
|
+
*/
|
|
369
|
+
export declare function matchOriginDevice(clientIp: string, reg: DeviceRegistry): {
|
|
370
|
+
device: string;
|
|
371
|
+
user?: string;
|
|
372
|
+
} | undefined;
|
|
356
373
|
/**
|
|
357
374
|
* Collapse rows that resolve to the *same* session — a session with many
|
|
358
375
|
* subagent/fork PIDs (all matched to one transcript file) would otherwise print
|
|
@@ -26,7 +26,7 @@ import { AgentManager } from '../teams/agents.js';
|
|
|
26
26
|
import { getTerminalsDir } from '../state.js';
|
|
27
27
|
import { readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry } from './pid-registry.js';
|
|
28
28
|
import { loadHookSessionIndex, resolveHookSessionRecord } from './hook-sessions.js';
|
|
29
|
-
import { buildClaudeLabelMap } from './discover.js';
|
|
29
|
+
import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
|
|
30
30
|
import { buildRunNameMap } from './run-names.js';
|
|
31
31
|
import { latestSessionFileForCwd } from './db.js';
|
|
32
32
|
import { extractSessionTopic } from './prompt.js';
|
|
@@ -34,6 +34,7 @@ import { readSessionTailWithRaw } from './tail.js';
|
|
|
34
34
|
import { computeTokPerSec } from './throughput.js';
|
|
35
35
|
import { inferSessionState } from './state.js';
|
|
36
36
|
import { detectProvenance } from './provenance.js';
|
|
37
|
+
import { loadDevices } from '../devices/registry.js';
|
|
37
38
|
import { presenceFromStore } from './detached.js';
|
|
38
39
|
import { mapBounded } from '../concurrency.js';
|
|
39
40
|
const execFileAsync = promisify(execFile);
|
|
@@ -66,7 +67,6 @@ export function activeStatusFromCloudStatus(status) {
|
|
|
66
67
|
return 'queued';
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
|
-
const HOME = os.homedir();
|
|
70
70
|
const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
|
|
71
71
|
/**
|
|
72
72
|
* A process is classified `running` if its session file was touched in the
|
|
@@ -216,9 +216,45 @@ function claudeProjectDirName(cwd) {
|
|
|
216
216
|
* Locate the active Claude session file for a process. If we know the session
|
|
217
217
|
* UUID (from terminal env or team parent), prefer the exact match. Otherwise
|
|
218
218
|
* fall back to the most-recent-mtime .jsonl in the project's folder.
|
|
219
|
+
*
|
|
220
|
+
* Searches EVERY version-home project root, not just the live `~/.claude`
|
|
221
|
+
* symlink. `~/.claude` points at the currently-installed agent version; a
|
|
222
|
+
* session launched under an EARLIER version keeps its transcript under that
|
|
223
|
+
* version's home (`…/.history/versions/claude/<ver>/home/.claude/projects/`).
|
|
224
|
+
* Resolving only `~/.claude/projects` meant that the instant a newer version
|
|
225
|
+
* was installed, every still-running older-version session lost its transcript
|
|
226
|
+
* here — no `sessionFile`, so no start/activity time, so `agents sessions`
|
|
227
|
+
* rendered it `unknown` and the watchdog skipped it as "no activity timestamp".
|
|
228
|
+
* `getAgentSessionDirs('claude','projects')` is the same version-aware enumerator
|
|
229
|
+
* the rest of the CLI uses, so this stays in lockstep with discovery.
|
|
219
230
|
*/
|
|
220
231
|
function findClaudeSessionFile(cwd, sessionId) {
|
|
221
|
-
return
|
|
232
|
+
return pickClaudeSessionFileAcrossRoots(getAgentSessionDirs('claude', 'projects'), cwd, sessionId);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Resolve a Claude transcript for `cwd` across the given project roots, newest
|
|
236
|
+
* mtime winning when the same id/cwd resolves in more than one version home
|
|
237
|
+
* (the actively-written copy is the newest). Pure over `projectRoots`, so it is
|
|
238
|
+
* testable against temp dirs without touching the real home directory.
|
|
239
|
+
*/
|
|
240
|
+
export function pickClaudeSessionFileAcrossRoots(projectRoots, cwd, sessionId) {
|
|
241
|
+
const enc = claudeProjectDirName(cwd);
|
|
242
|
+
let best;
|
|
243
|
+
for (const root of projectRoots) {
|
|
244
|
+
const hit = pickSessionFile(path.join(root, enc), sessionId);
|
|
245
|
+
if (!hit)
|
|
246
|
+
continue;
|
|
247
|
+
let mtime;
|
|
248
|
+
try {
|
|
249
|
+
mtime = fs.statSync(hit).mtimeMs;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!best || mtime > best.mtime)
|
|
255
|
+
best = { path: hit, mtime };
|
|
256
|
+
}
|
|
257
|
+
return best?.path;
|
|
222
258
|
}
|
|
223
259
|
/**
|
|
224
260
|
* Pick a Claude transcript file within a project dir.
|
|
@@ -1081,8 +1117,11 @@ export async function listTmuxAgentSessions() {
|
|
|
1081
1117
|
const pidAlive = pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true;
|
|
1082
1118
|
const { state, tokPerSec } = computeLiveSignals(id.agent, sessionFile, cwd, pidAlive);
|
|
1083
1119
|
const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1120
|
+
// The mux/reply rails are known exactly here (the pane IS a tmux pane), so we
|
|
1121
|
+
// stamp them off the pane. `transport:'local'` is only a placeholder: the pane
|
|
1122
|
+
// can't reveal how the shell above it was reached. enrichProvenance later reads
|
|
1123
|
+
// the pane process's env and upgrades this to 'ssh' (with the real origin) when
|
|
1124
|
+
// SSH_CONNECTION is present, while preserving this mux/reply.
|
|
1086
1125
|
const provenance = {
|
|
1087
1126
|
host: os.hostname(),
|
|
1088
1127
|
transport: 'local',
|
|
@@ -1135,6 +1174,7 @@ export async function getActiveSessions(opts = {}) {
|
|
|
1135
1174
|
const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
|
|
1136
1175
|
const merged = dedupeBySession([...tmuxAgents, ...teams, ...terminals, ...cloud, ...unattributed]);
|
|
1137
1176
|
await enrichProvenance(merged);
|
|
1177
|
+
await resolveOrigins(merged);
|
|
1138
1178
|
foldPresence(merged);
|
|
1139
1179
|
return merged;
|
|
1140
1180
|
}
|
|
@@ -1161,14 +1201,72 @@ function foldPresence(rows) {
|
|
|
1161
1201
|
* each session once, not once per fork pid. Probes run in parallel — each is a
|
|
1162
1202
|
* single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
|
|
1163
1203
|
* undefined rather than blocking the listing.
|
|
1204
|
+
*
|
|
1205
|
+
* A row that already carries provenance (the tmux path, which knows its exact
|
|
1206
|
+
* mux/reply from the pane) is not skipped — it is probe-and-MERGED. The tmux
|
|
1207
|
+
* path can only stamp a `transport:'local'` placeholder because the pane alone
|
|
1208
|
+
* doesn't reveal how the shell above it was reached; the process env does. So we
|
|
1209
|
+
* still read the env and fill in the real SSH origin/term, while preserving the
|
|
1210
|
+
* authoritative mux/reply the pane already gave us. Skipping this (the old
|
|
1211
|
+
* behavior) is exactly why ssh-launched tmux sessions rendered as local.
|
|
1164
1212
|
*/
|
|
1165
1213
|
async function enrichProvenance(sessions) {
|
|
1166
1214
|
await Promise.all(sessions.map(async (s) => {
|
|
1167
|
-
if (
|
|
1215
|
+
if (!s.pid)
|
|
1168
1216
|
return;
|
|
1169
|
-
|
|
1217
|
+
const probed = await detectProvenance(s.pid);
|
|
1218
|
+
if (!probed)
|
|
1219
|
+
return;
|
|
1220
|
+
if (!s.provenance) {
|
|
1221
|
+
s.provenance = probed;
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
// Row already carries exact mux/reply (the tmux path). Fill only what a
|
|
1225
|
+
// pre-set provenance can't know from the pane alone: the real launch origin.
|
|
1226
|
+
if (probed.transport === 'ssh' && !s.provenance.ssh) {
|
|
1227
|
+
s.provenance.transport = 'ssh';
|
|
1228
|
+
s.provenance.ssh = probed.ssh;
|
|
1229
|
+
}
|
|
1230
|
+
if (probed.term && !s.provenance.term)
|
|
1231
|
+
s.provenance.term = probed.term;
|
|
1170
1232
|
}));
|
|
1171
1233
|
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Match an SSH client IP to a registered device (pure — testable with a plain
|
|
1236
|
+
* registry object). Returns the device name + ssh login user when the IP is a
|
|
1237
|
+
* known device address.
|
|
1238
|
+
*/
|
|
1239
|
+
export function matchOriginDevice(clientIp, reg) {
|
|
1240
|
+
for (const d of Object.values(reg)) {
|
|
1241
|
+
if (d.address?.ip && d.address.ip === clientIp) {
|
|
1242
|
+
return { device: d.name, ...(d.user ? { user: d.user } : {}) };
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return undefined;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Resolve the initiating device for every ssh-transport session by matching its
|
|
1249
|
+
* `ssh.clientIp` against the device registry. Read-only and best-effort: a
|
|
1250
|
+
* registry that can't be loaded, or an IP that matches no device, leaves
|
|
1251
|
+
* `origin` undefined (the raw client IP is still on `ssh`). Mutates in place.
|
|
1252
|
+
*/
|
|
1253
|
+
async function resolveOrigins(sessions) {
|
|
1254
|
+
const needing = sessions.filter((s) => s.provenance?.ssh && !s.provenance.origin);
|
|
1255
|
+
if (needing.length === 0)
|
|
1256
|
+
return;
|
|
1257
|
+
let reg;
|
|
1258
|
+
try {
|
|
1259
|
+
reg = await loadDevices();
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
for (const s of needing) {
|
|
1265
|
+
const match = matchOriginDevice(s.provenance.ssh.clientIp, reg);
|
|
1266
|
+
if (match)
|
|
1267
|
+
s.provenance.origin = match;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1172
1270
|
/**
|
|
1173
1271
|
* Identity for a row the scan could not tie to a session: a daemon's worker
|
|
1174
1272
|
* processes (an OpenClaw gateway spawning `codex`, a supervisor pool) have no
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -40,6 +40,11 @@ export interface SessionRow {
|
|
|
40
40
|
worktree_slug: string | null;
|
|
41
41
|
ticket_id: string | null;
|
|
42
42
|
plan: string | null;
|
|
43
|
+
machine: string | null;
|
|
44
|
+
todos: string | null;
|
|
45
|
+
recent_directories_touched: string | null;
|
|
46
|
+
linear_project: string | null;
|
|
47
|
+
linear_project_url: string | null;
|
|
43
48
|
}
|
|
44
49
|
/** File stat snapshot used to detect changes between scan runs. */
|
|
45
50
|
export interface ScanStamp {
|
|
@@ -215,6 +220,8 @@ export declare function isSessionActivityFresh(row: {
|
|
|
215
220
|
timestamp: string;
|
|
216
221
|
file_mtime_ms: number | null;
|
|
217
222
|
}, maxAgeMs: number, nowMs: number): boolean;
|
|
223
|
+
/** Persist a lazily resolved Linear project without reparsing the transcript. */
|
|
224
|
+
export declare function cacheLinearProject(sessionId: string, project: string, projectUrl: string): void;
|
|
218
225
|
/**
|
|
219
226
|
* Newest indexed session file for an agent working in `cwd`. Lets the live
|
|
220
227
|
* `--active` scanner locate a Codex transcript (whose files are date-partitioned,
|
|
@@ -242,6 +249,42 @@ export interface UsageRollupRow {
|
|
|
242
249
|
}
|
|
243
250
|
/** What to group a usage rollup by. */
|
|
244
251
|
export type UsageRollupGroup = 'agent' | 'project' | 'day';
|
|
252
|
+
/**
|
|
253
|
+
* Smart-launch affinity priors: group sessions by origin machine, harness, or
|
|
254
|
+
* joint (machine + agent). Ordered by launch count desc.
|
|
255
|
+
*
|
|
256
|
+
* SQL shape (device example):
|
|
257
|
+
* SELECT machine, COUNT(*) launches, SUM(duration_ms), SUM(token_count)
|
|
258
|
+
* FROM sessions
|
|
259
|
+
* WHERE timestamp >= ? AND origin = 'cli' AND is_team_origin = 0
|
|
260
|
+
* GROUP BY machine ORDER BY launches DESC
|
|
261
|
+
*
|
|
262
|
+
* Account rotation is NOT done here — that stays on live rate-limit windows
|
|
263
|
+
* via `--strategy balanced` / rotate.ts.
|
|
264
|
+
*/
|
|
265
|
+
export type AffinityGroup = 'machine' | 'agent' | 'machine_agent';
|
|
266
|
+
export interface AffinityRow {
|
|
267
|
+
/** Group key: machine name, agent id, or "machine\\tagent". */
|
|
268
|
+
key: string;
|
|
269
|
+
machine?: string;
|
|
270
|
+
agent?: string;
|
|
271
|
+
launches: number;
|
|
272
|
+
durationMs: number;
|
|
273
|
+
tokenCount: number;
|
|
274
|
+
costUsd: number;
|
|
275
|
+
}
|
|
276
|
+
export declare function queryAffinityRollup(options: {
|
|
277
|
+
groupBy: AffinityGroup;
|
|
278
|
+
/** ISO cutoff or ms; defaults to 14 days ago when omitted. */
|
|
279
|
+
sinceMs?: number;
|
|
280
|
+
/** Restrict to these harnesses (e.g. claude/codex/kimi). */
|
|
281
|
+
agents?: SessionAgentId[];
|
|
282
|
+
/** Default true: only origin=cli rows. */
|
|
283
|
+
onlyCli?: boolean;
|
|
284
|
+
/** Default true: drop team-spawned sessions. */
|
|
285
|
+
excludeTeamOrigin?: boolean;
|
|
286
|
+
project?: string;
|
|
287
|
+
}): AffinityRow[];
|
|
245
288
|
/**
|
|
246
289
|
* Aggregate cost / duration / tokens across sessions, grouped by agent,
|
|
247
290
|
* project, or calendar day. Honors the same filter shape as querySessions
|
package/dist/lib/session/db.js
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import Database from '../sqlite.js';
|
|
12
|
+
import { parseSession } from './parse.js';
|
|
13
|
+
import { extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
|
|
12
14
|
import { getSessionsDir, getSessionsDbPath } from '../state.js';
|
|
15
|
+
import { machineForSessionFile } from './origin-machine.js';
|
|
13
16
|
const SESSIONS_DIR = getSessionsDir();
|
|
14
17
|
const DB_PATH = getSessionsDbPath();
|
|
15
18
|
/** Current schema version; bumped when migrations are added. */
|
|
16
|
-
const SCHEMA_VERSION =
|
|
19
|
+
const SCHEMA_VERSION = 18;
|
|
17
20
|
/**
|
|
18
21
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
19
22
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -69,13 +72,20 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
69
72
|
pr_number INTEGER,
|
|
70
73
|
worktree_slug TEXT,
|
|
71
74
|
ticket_id TEXT,
|
|
72
|
-
plan TEXT
|
|
75
|
+
plan TEXT,
|
|
76
|
+
machine TEXT,
|
|
77
|
+
todos TEXT,
|
|
78
|
+
recent_directories_touched TEXT,
|
|
79
|
+
linear_project TEXT,
|
|
80
|
+
linear_project_url TEXT
|
|
73
81
|
);
|
|
74
82
|
CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
|
|
75
83
|
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
76
84
|
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
|
|
77
85
|
CREATE INDEX IF NOT EXISTS idx_sessions_file_path ON sessions(file_path);
|
|
78
86
|
CREATE INDEX IF NOT EXISTS idx_sessions_short_id ON sessions(short_id);
|
|
87
|
+
-- idx_sessions_machine_ts / idx_sessions_agent_ts are created after migration
|
|
88
|
+
-- v17 guarantees the machine column exists (same pattern as last_activity).
|
|
79
89
|
|
|
80
90
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
|
|
81
91
|
session_id UNINDEXED,
|
|
@@ -316,6 +326,40 @@ function migrateSchema(db, fromVersion) {
|
|
|
316
326
|
// primary key — so every corrupt row becomes addressable. No rescan needed.
|
|
317
327
|
db.exec(`UPDATE sessions SET short_id = substr(id, 1, 8) WHERE short_id IS NULL OR short_id = ''`);
|
|
318
328
|
}
|
|
329
|
+
if (fromVersion < 17) {
|
|
330
|
+
// v16 → v17 (main): todos / recent dirs / linear project metadata.
|
|
331
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
332
|
+
if (!cols.some(c => c.name === 'todos'))
|
|
333
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN todos TEXT`);
|
|
334
|
+
if (!cols.some(c => c.name === 'recent_directories_touched'))
|
|
335
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN recent_directories_touched TEXT`);
|
|
336
|
+
if (!cols.some(c => c.name === 'linear_project'))
|
|
337
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN linear_project TEXT`);
|
|
338
|
+
if (!cols.some(c => c.name === 'linear_project_url'))
|
|
339
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN linear_project_url TEXT`);
|
|
340
|
+
db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
|
|
341
|
+
}
|
|
342
|
+
if (fromVersion < 18) {
|
|
343
|
+
// v17 → v18: persist origin machine for smart-launch affinity GROUP BY machine.
|
|
344
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
345
|
+
if (!cols.some(c => c.name === 'machine')) {
|
|
346
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN machine TEXT`);
|
|
347
|
+
}
|
|
348
|
+
db.exec(`
|
|
349
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC);
|
|
350
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC);
|
|
351
|
+
`);
|
|
352
|
+
const rows = db
|
|
353
|
+
.prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
|
|
354
|
+
.all();
|
|
355
|
+
const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
|
|
356
|
+
const txn = db.transaction((items) => {
|
|
357
|
+
for (const r of items) {
|
|
358
|
+
upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
txn(rows);
|
|
362
|
+
}
|
|
319
363
|
}
|
|
320
364
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
321
365
|
export function getDB() {
|
|
@@ -349,6 +393,27 @@ export function getDB() {
|
|
|
349
393
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
|
|
350
394
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
|
|
351
395
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
|
|
396
|
+
// machine column + indexes: only after the column is guaranteed present.
|
|
397
|
+
// Fresh SCHEMA (v17) includes the column; older DBs get it from migrate v17.
|
|
398
|
+
// If a partial upgrade left schema_version ahead of the column, repair here.
|
|
399
|
+
{
|
|
400
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
401
|
+
if (!cols.some((c) => c.name === 'machine')) {
|
|
402
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN machine TEXT`);
|
|
403
|
+
const rows = db
|
|
404
|
+
.prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
|
|
405
|
+
.all();
|
|
406
|
+
const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
|
|
407
|
+
const txn = db.transaction((items) => {
|
|
408
|
+
for (const r of items) {
|
|
409
|
+
upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
txn(rows);
|
|
413
|
+
}
|
|
414
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC)`);
|
|
415
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC)`);
|
|
416
|
+
}
|
|
352
417
|
// One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
|
|
353
418
|
// them anymore. Guarded by a meta flag so we only try once.
|
|
354
419
|
const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
|
|
@@ -656,14 +721,16 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
656
721
|
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
657
722
|
output_tokens, cost_usd, duration_ms,
|
|
658
723
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
659
|
-
pr_url, pr_number, worktree_slug, ticket_id, plan
|
|
724
|
+
pr_url, pr_number, worktree_slug, ticket_id, plan, todos,
|
|
725
|
+
recent_directories_touched, linear_project, linear_project_url, machine
|
|
660
726
|
) VALUES (
|
|
661
727
|
@id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
|
|
662
728
|
@version, @account, @timestamp, @last_activity,
|
|
663
729
|
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
664
730
|
@output_tokens, @cost_usd, @duration_ms,
|
|
665
731
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
666
|
-
@pr_url, @pr_number, @worktree_slug, @ticket_id, @plan
|
|
732
|
+
@pr_url, @pr_number, @worktree_slug, @ticket_id, @plan, @todos,
|
|
733
|
+
@recent_directories_touched, @linear_project, @linear_project_url, @machine
|
|
667
734
|
)
|
|
668
735
|
ON CONFLICT(id) DO UPDATE SET
|
|
669
736
|
short_id = excluded.short_id,
|
|
@@ -702,8 +769,35 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
702
769
|
pr_number = excluded.pr_number,
|
|
703
770
|
worktree_slug = excluded.worktree_slug,
|
|
704
771
|
ticket_id = excluded.ticket_id,
|
|
705
|
-
plan = excluded.plan
|
|
772
|
+
plan = excluded.plan,
|
|
773
|
+
todos = excluded.todos,
|
|
774
|
+
recent_directories_touched = excluded.recent_directories_touched,
|
|
775
|
+
linear_project = CASE
|
|
776
|
+
WHEN excluded.ticket_id IS NOT sessions.ticket_id THEN excluded.linear_project
|
|
777
|
+
ELSE COALESCE(excluded.linear_project, sessions.linear_project)
|
|
778
|
+
END,
|
|
779
|
+
linear_project_url = CASE
|
|
780
|
+
WHEN excluded.ticket_id IS NOT sessions.ticket_id THEN excluded.linear_project_url
|
|
781
|
+
ELSE COALESCE(excluded.linear_project_url, sessions.linear_project_url)
|
|
782
|
+
END,
|
|
783
|
+
machine = excluded.machine
|
|
706
784
|
`);
|
|
785
|
+
function enrichCachedSessionMeta(meta) {
|
|
786
|
+
if (!meta.filePath)
|
|
787
|
+
return meta;
|
|
788
|
+
try {
|
|
789
|
+
const events = parseSession(meta.filePath, meta.agent);
|
|
790
|
+
return {
|
|
791
|
+
...meta,
|
|
792
|
+
todos: extractTodoProgressFromEvents(events),
|
|
793
|
+
recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
// Synthetic/cloud rows can intentionally name a transcript that is not local.
|
|
798
|
+
return meta;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
707
801
|
const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
|
|
708
802
|
const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
|
|
709
803
|
// Read back the label the upsert actually stored (which may be the preserved
|
|
@@ -732,11 +826,18 @@ function storedFtsLabel(readLabel, id) {
|
|
|
732
826
|
const row = readLabel.get(id);
|
|
733
827
|
return row?.label ?? '';
|
|
734
828
|
}
|
|
829
|
+
/** Resolve origin machine for a row: prefer caller-stamped meta, else path. */
|
|
830
|
+
function resolveMachine(meta) {
|
|
831
|
+
if (meta.machine && meta.machine.trim())
|
|
832
|
+
return meta.machine.trim();
|
|
833
|
+
return machineForSessionFile(meta.filePath, meta.agent);
|
|
834
|
+
}
|
|
735
835
|
/**
|
|
736
836
|
* Upsert a session row and replace its FTS5 content in a single transaction.
|
|
737
837
|
* `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
|
|
738
838
|
*/
|
|
739
839
|
export function upsertSession(meta, content, scan) {
|
|
840
|
+
meta = enrichCachedSessionMeta(meta);
|
|
740
841
|
const db = getDB();
|
|
741
842
|
const { upsert, delText, insText, readLabel } = stmts(db);
|
|
742
843
|
const row = {
|
|
@@ -770,6 +871,11 @@ export function upsertSession(meta, content, scan) {
|
|
|
770
871
|
worktree_slug: meta.worktreeSlug ?? null,
|
|
771
872
|
ticket_id: meta.ticketId ?? null,
|
|
772
873
|
plan: meta.plan ?? null,
|
|
874
|
+
todos: meta.todos ? JSON.stringify(meta.todos) : null,
|
|
875
|
+
recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
|
|
876
|
+
linear_project: meta.linearProject ?? null,
|
|
877
|
+
linear_project_url: meta.linearProjectUrl ?? null,
|
|
878
|
+
machine: resolveMachine(meta),
|
|
773
879
|
};
|
|
774
880
|
const txn = db.transaction(() => {
|
|
775
881
|
upsert.run(row);
|
|
@@ -812,6 +918,9 @@ export function upsertSessionsBatch(entries) {
|
|
|
812
918
|
const byPath = new Map(entries
|
|
813
919
|
.filter(e => e.scan && e.meta.filePath)
|
|
814
920
|
.map(e => [canonicalLedgerKey(e.meta.filePath), e]));
|
|
921
|
+
const enrichedEntries = entries.map(entry => entry.meta.agent === 'claude' || entry.meta.agent === 'codex'
|
|
922
|
+
? entry
|
|
923
|
+
: { ...entry, meta: enrichCachedSessionMeta(entry.meta) });
|
|
815
924
|
const txn = db.transaction((items) => {
|
|
816
925
|
// Re-read the ledger now that we hold the write lock. Any file committed
|
|
817
926
|
// by a concurrent process since our pre-scan is visible here.
|
|
@@ -873,6 +982,11 @@ export function upsertSessionsBatch(entries) {
|
|
|
873
982
|
worktree_slug: meta.worktreeSlug ?? null,
|
|
874
983
|
ticket_id: meta.ticketId ?? null,
|
|
875
984
|
plan: meta.plan ?? null,
|
|
985
|
+
todos: meta.todos ? JSON.stringify(meta.todos) : null,
|
|
986
|
+
recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
|
|
987
|
+
linear_project: meta.linearProject ?? null,
|
|
988
|
+
linear_project_url: meta.linearProjectUrl ?? null,
|
|
989
|
+
machine: resolveMachine(meta),
|
|
876
990
|
});
|
|
877
991
|
delText.run(meta.id);
|
|
878
992
|
insText.run(meta.id,
|
|
@@ -890,7 +1004,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
890
1004
|
}
|
|
891
1005
|
}
|
|
892
1006
|
});
|
|
893
|
-
txn(
|
|
1007
|
+
txn(enrichedEntries);
|
|
894
1008
|
}
|
|
895
1009
|
/**
|
|
896
1010
|
* Sync labels for a set of sessions. For each id in the map, if the stored
|
|
@@ -1053,8 +1167,23 @@ function rowToMeta(row) {
|
|
|
1053
1167
|
worktreeSlug: row.worktree_slug ?? undefined,
|
|
1054
1168
|
ticketId: row.ticket_id ?? undefined,
|
|
1055
1169
|
plan: row.plan ?? undefined,
|
|
1170
|
+
todos: parseJsonColumn(row.todos),
|
|
1171
|
+
recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
|
|
1172
|
+
linearProject: row.linear_project ?? undefined,
|
|
1173
|
+
linearProjectUrl: row.linear_project_url ?? undefined,
|
|
1174
|
+
machine: row.machine ?? undefined,
|
|
1056
1175
|
};
|
|
1057
1176
|
}
|
|
1177
|
+
function parseJsonColumn(value) {
|
|
1178
|
+
if (!value)
|
|
1179
|
+
return undefined;
|
|
1180
|
+
try {
|
|
1181
|
+
return JSON.parse(value);
|
|
1182
|
+
}
|
|
1183
|
+
catch {
|
|
1184
|
+
return undefined;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1058
1187
|
/**
|
|
1059
1188
|
* The recency signal used to sort and label the listing: last-message time when
|
|
1060
1189
|
* a parser computed it (`meta.lastActivity` from `lastTsMs`), else the file's
|
|
@@ -1075,6 +1204,11 @@ export function isSessionActivityFresh(row, maxAgeMs, nowMs) {
|
|
|
1075
1204
|
const activityMs = Number.isFinite(parsedActivityMs) ? parsedActivityMs : row.file_mtime_ms ?? undefined;
|
|
1076
1205
|
return activityMs != null && nowMs - activityMs <= maxAgeMs;
|
|
1077
1206
|
}
|
|
1207
|
+
/** Persist a lazily resolved Linear project without reparsing the transcript. */
|
|
1208
|
+
export function cacheLinearProject(sessionId, project, projectUrl) {
|
|
1209
|
+
getDB().prepare(`UPDATE sessions SET linear_project = ?, linear_project_url = ? WHERE id = ?`)
|
|
1210
|
+
.run(project, projectUrl, sessionId);
|
|
1211
|
+
}
|
|
1078
1212
|
/**
|
|
1079
1213
|
* Newest indexed session file for an agent working in `cwd`. Lets the live
|
|
1080
1214
|
* `--active` scanner locate a Codex transcript (whose files are date-partitioned,
|
|
@@ -1210,6 +1344,57 @@ export function countSessions(options = {}) {
|
|
|
1210
1344
|
const row = db.prepare(sql).get(...params);
|
|
1211
1345
|
return row ? row.n : 0;
|
|
1212
1346
|
}
|
|
1347
|
+
export function queryAffinityRollup(options) {
|
|
1348
|
+
const db = getDB();
|
|
1349
|
+
const where = [];
|
|
1350
|
+
const params = [];
|
|
1351
|
+
const sinceMs = options.sinceMs ?? (Date.now() - 14 * 24 * 60 * 60 * 1000);
|
|
1352
|
+
// ISO timestamps sort lexicographically; compare as string prefix of datetime.
|
|
1353
|
+
where.push(`timestamp >= ?`);
|
|
1354
|
+
params.push(new Date(sinceMs).toISOString());
|
|
1355
|
+
if (options.onlyCli !== false) {
|
|
1356
|
+
where.push(`IFNULL(origin, 'cli') = 'cli'`);
|
|
1357
|
+
}
|
|
1358
|
+
if (options.excludeTeamOrigin !== false) {
|
|
1359
|
+
where.push(`IFNULL(is_team_origin, 0) = 0`);
|
|
1360
|
+
}
|
|
1361
|
+
if (options.agents && options.agents.length > 0) {
|
|
1362
|
+
where.push(`agent IN (${options.agents.map(() => '?').join(',')})`);
|
|
1363
|
+
params.push(...options.agents);
|
|
1364
|
+
}
|
|
1365
|
+
if (options.project) {
|
|
1366
|
+
where.push(`LOWER(IFNULL(project, '')) LIKE ?`);
|
|
1367
|
+
params.push(`%${options.project.toLowerCase()}%`);
|
|
1368
|
+
}
|
|
1369
|
+
let keyExpr;
|
|
1370
|
+
let selectExtra;
|
|
1371
|
+
if (options.groupBy === 'machine') {
|
|
1372
|
+
keyExpr = `IFNULL(NULLIF(machine, ''), '(unknown)')`;
|
|
1373
|
+
selectExtra = `${keyExpr} AS key, ${keyExpr} AS machine, NULL AS agent`;
|
|
1374
|
+
}
|
|
1375
|
+
else if (options.groupBy === 'agent') {
|
|
1376
|
+
keyExpr = `agent`;
|
|
1377
|
+
selectExtra = `agent AS key, NULL AS machine, agent AS agent`;
|
|
1378
|
+
}
|
|
1379
|
+
else {
|
|
1380
|
+
keyExpr = `IFNULL(NULLIF(machine, ''), '(unknown)') || char(9) || agent`;
|
|
1381
|
+
selectExtra = `${keyExpr} AS key, IFNULL(NULLIF(machine, ''), '(unknown)') AS machine, agent AS agent`;
|
|
1382
|
+
}
|
|
1383
|
+
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
1384
|
+
const sql = `
|
|
1385
|
+
SELECT
|
|
1386
|
+
${selectExtra},
|
|
1387
|
+
COUNT(*) AS launches,
|
|
1388
|
+
IFNULL(SUM(duration_ms), 0) AS durationMs,
|
|
1389
|
+
IFNULL(SUM(token_count), 0) AS tokenCount,
|
|
1390
|
+
IFNULL(SUM(cost_usd), 0) AS costUsd
|
|
1391
|
+
FROM sessions
|
|
1392
|
+
${clause}
|
|
1393
|
+
GROUP BY key
|
|
1394
|
+
ORDER BY launches DESC, key ASC
|
|
1395
|
+
`;
|
|
1396
|
+
return db.prepare(sql).all(...params);
|
|
1397
|
+
}
|
|
1213
1398
|
/**
|
|
1214
1399
|
* Aggregate cost / duration / tokens across sessions, grouped by agent,
|
|
1215
1400
|
* project, or calendar day. Honors the same filter shape as querySessions
|