@phnx-labs/agents-cli 1.20.77 → 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 +102 -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 +29 -3
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +4 -2
- package/dist/commands/secrets.js +8 -2
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -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 +15 -1
- package/dist/commands/sessions-picker.js +58 -4
- package/dist/commands/sessions.d.ts +95 -1
- package/dist/commands/sessions.js +224 -26
- package/dist/commands/ssh.js +9 -1
- package/dist/index.js +3 -5
- 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/daemon.d.ts +9 -41
- package/dist/lib/daemon.js +16 -117
- 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/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/hosts/remote-cmd.js +2 -0
- 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/runner.js +29 -26
- package/dist/lib/sandbox.js +0 -16
- 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 +2 -0
- package/dist/lib/secrets/agent.js +26 -14
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +16 -8
- 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 +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/session/active.d.ts +7 -0
- package/dist/lib/session/active.js +38 -3
- package/dist/lib/session/db.d.ts +37 -0
- package/dist/lib/session/db.js +112 -4
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +15 -2
- package/dist/lib/session/discover.js +19 -11
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/package.json +1 -1
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
|
@@ -31,7 +31,8 @@ import { emit } from '../events.js';
|
|
|
31
31
|
import { readMeta, getHelpersDir } from '../state.js';
|
|
32
32
|
import { assertNameActiveInResourceProfile, filterNamesForActiveResourceProfile } from '../resource-profiles.js';
|
|
33
33
|
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
|
|
34
|
-
import {
|
|
34
|
+
import { GLOBAL_HARNESS } from './scope.js';
|
|
35
|
+
import { resolveSession, deleteSession } from './session-store.js';
|
|
35
36
|
import { createHash } from 'node:crypto';
|
|
36
37
|
const keychainStore = {
|
|
37
38
|
has: hasKeychainToken,
|
|
@@ -1043,7 +1044,7 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
1043
1044
|
*
|
|
1044
1045
|
* A read in a macOS headless context resolves broker-only (agentOnly) and fails
|
|
1045
1046
|
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
1046
|
-
* the per-caller pattern
|
|
1047
|
+
* the per-caller broker-only pattern used across the headless secrets readers.
|
|
1047
1048
|
*/
|
|
1048
1049
|
export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
|
|
1049
1050
|
// Injected so the TTY branch below is testable: it is the branch that decides a
|
|
@@ -1097,7 +1098,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1097
1098
|
// file-backed bundle has none to dedup. The never-unlocked path is a single
|
|
1098
1099
|
// stat (agentSocketExists) so it costs nothing when the agent isn't running.
|
|
1099
1100
|
if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
1100
|
-
|
|
1101
|
+
// The scope this reader asks under. Falls back to the GLOBAL scope, not to a
|
|
1102
|
+
// literal `'cli'` harness — the broker and the durable store both resolve
|
|
1103
|
+
// own-harness → global (bundleScopeChain), so an unscoped unlock is visible
|
|
1104
|
+
// here whether this process was launched by an agent or typed in a terminal.
|
|
1105
|
+
const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
|
|
1101
1106
|
const hit = agentGetSync(name, harness);
|
|
1102
1107
|
if (hit) {
|
|
1103
1108
|
// The agent stores the FULL bundle env. Apply the same subset filter and
|
|
@@ -1122,13 +1127,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1122
1127
|
// Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
|
|
1123
1128
|
// across restart — this fixes BOTH the interactive re-prompt and the headless
|
|
1124
1129
|
// throw below (which now fires only when there is genuinely no session).
|
|
1125
|
-
const
|
|
1126
|
-
if (
|
|
1130
|
+
const resolved = resolveSession(name, Date.now(), harness);
|
|
1131
|
+
if (resolved) {
|
|
1132
|
+
const session = resolved.entry;
|
|
1127
1133
|
const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
|
|
1128
1134
|
stampLastUsed(filtered.bundle);
|
|
1129
1135
|
// Re-warm the broker with the remaining TTL so later reads hit RAM and
|
|
1130
|
-
// `agents secrets status` is honest.
|
|
1131
|
-
|
|
1136
|
+
// `agents secrets status` is honest. Re-warm under the scope the grant was
|
|
1137
|
+
// MADE in (resolved.harness), never the asking scope — re-warming a global
|
|
1138
|
+
// grant as `claude` would silently narrow it for every other harness.
|
|
1139
|
+
agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness);
|
|
1132
1140
|
emit('secrets.get', {
|
|
1133
1141
|
module: 'secrets',
|
|
1134
1142
|
bundle: name,
|
|
@@ -1323,7 +1331,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1323
1331
|
bundlePolicy(bundle) === 'daily' &&
|
|
1324
1332
|
secretsAgentAutoEnabled() &&
|
|
1325
1333
|
canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
|
|
1326
|
-
agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME ||
|
|
1334
|
+
agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS);
|
|
1327
1335
|
}
|
|
1328
1336
|
return { bundle, env };
|
|
1329
1337
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness scoping for an unlocked bundle — the shared vocabulary of the broker
|
|
3
|
+
* (agent.ts), the durable session store (session-store.ts), and the read path
|
|
4
|
+
* (bundles.ts).
|
|
5
|
+
*
|
|
6
|
+
* A grant is stored under a scope and read under a scope; the two must agree or
|
|
7
|
+
* the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
|
|
8
|
+
* a grant to one harness, so an unlock without it is global by definition.
|
|
9
|
+
*
|
|
10
|
+
* This module deliberately has NO imports: agent.ts and session-store.ts already
|
|
11
|
+
* import each other, and hanging the scope constants off either one would close
|
|
12
|
+
* that cycle — under ESM a cyclic `const` read can land in the temporal dead zone
|
|
13
|
+
* and throw at runtime even though tsc is happy.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Scope of an unlock that was not narrowed with `--for`: readable by every
|
|
17
|
+
* harness. Not a valid harness name, so it can never collide with one.
|
|
18
|
+
*/
|
|
19
|
+
export declare const GLOBAL_HARNESS = "*";
|
|
20
|
+
/**
|
|
21
|
+
* Scopes a reader consults, most specific first: its own harness, then the global
|
|
22
|
+
* grant. This is the resolution order of the scoped-grant model — a narrow
|
|
23
|
+
* `--for claude` unlock stays claude-only while an unscoped unlock serves
|
|
24
|
+
* everyone — not a fallback papering over a miss.
|
|
25
|
+
*/
|
|
26
|
+
export declare function bundleScopeChain(harness: string | undefined): string[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness scoping for an unlocked bundle — the shared vocabulary of the broker
|
|
3
|
+
* (agent.ts), the durable session store (session-store.ts), and the read path
|
|
4
|
+
* (bundles.ts).
|
|
5
|
+
*
|
|
6
|
+
* A grant is stored under a scope and read under a scope; the two must agree or
|
|
7
|
+
* the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
|
|
8
|
+
* a grant to one harness, so an unlock without it is global by definition.
|
|
9
|
+
*
|
|
10
|
+
* This module deliberately has NO imports: agent.ts and session-store.ts already
|
|
11
|
+
* import each other, and hanging the scope constants off either one would close
|
|
12
|
+
* that cycle — under ESM a cyclic `const` read can land in the temporal dead zone
|
|
13
|
+
* and throw at runtime even though tsc is happy.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Scope of an unlock that was not narrowed with `--for`: readable by every
|
|
17
|
+
* harness. Not a valid harness name, so it can never collide with one.
|
|
18
|
+
*/
|
|
19
|
+
export const GLOBAL_HARNESS = '*';
|
|
20
|
+
/**
|
|
21
|
+
* Scopes a reader consults, most specific first: its own harness, then the global
|
|
22
|
+
* grant. This is the resolution order of the scoped-grant model — a narrow
|
|
23
|
+
* `--for claude` unlock stays claude-only while an unscoped unlock serves
|
|
24
|
+
* everyone — not a fallback papering over a miss.
|
|
25
|
+
*/
|
|
26
|
+
export function bundleScopeChain(harness) {
|
|
27
|
+
const own = harness || GLOBAL_HARNESS;
|
|
28
|
+
return own === GLOBAL_HARNESS ? [GLOBAL_HARNESS] : [own, GLOBAL_HARNESS];
|
|
29
|
+
}
|
|
@@ -74,6 +74,16 @@ export declare function readIndex(): SessionIndex;
|
|
|
74
74
|
export declare function writeIndex(index: SessionIndex): void;
|
|
75
75
|
/** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
|
|
76
76
|
export declare function saveSession(name: string, entry: SessionEntry): void;
|
|
77
|
+
/**
|
|
78
|
+
* Read a session honoring the scope chain: the caller's own harness first, then
|
|
79
|
+
* the global grant (see bundleScopeChain in scope.ts). This is the durable-store
|
|
80
|
+
* twin of the broker's `get` — both must resolve identically, or a bundle would
|
|
81
|
+
* be readable from RAM but not after a restart.
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveSession(name: string, now?: number, harness?: string): {
|
|
84
|
+
entry: SessionEntry;
|
|
85
|
+
harness: string;
|
|
86
|
+
} | null;
|
|
77
87
|
/** Read one session blob by known name. Null when absent/expired/malformed. */
|
|
78
88
|
export declare function loadSession(name: string, now?: number, harness?: string): SessionEntry | null;
|
|
79
89
|
/** Remove every persisted harness grant for one bundle. */
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* persistence is an optimization, not a correctness dependency.
|
|
26
26
|
*/
|
|
27
27
|
import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden } from './index.js';
|
|
28
|
+
import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
|
|
28
29
|
/** Prefix for all durable session items (device-local, no-ACL). */
|
|
29
30
|
export const SESSION_ITEM_PREFIX = 'agents-cli.session.';
|
|
30
31
|
/** Fixed-name index item — the ONLY thing we ever need to find without a known
|
|
@@ -116,7 +117,7 @@ export function saveSession(name, entry) {
|
|
|
116
117
|
if (!shouldPersist())
|
|
117
118
|
return;
|
|
118
119
|
try {
|
|
119
|
-
const harness = entry.harness ||
|
|
120
|
+
const harness = entry.harness || GLOBAL_HARNESS;
|
|
120
121
|
const key = `${harness}:${name}`;
|
|
121
122
|
setKeychainToken(sessionBlobItem(name, harness), JSON.stringify({ ...entry, harness }), { noAcl: true });
|
|
122
123
|
writeIndex(upsertEntry(readIndex(), key, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist, harness }));
|
|
@@ -125,8 +126,22 @@ export function saveSession(name, entry) {
|
|
|
125
126
|
/* best-effort — persistence is an optimization */
|
|
126
127
|
}
|
|
127
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Read a session honoring the scope chain: the caller's own harness first, then
|
|
131
|
+
* the global grant (see bundleScopeChain in scope.ts). This is the durable-store
|
|
132
|
+
* twin of the broker's `get` — both must resolve identically, or a bundle would
|
|
133
|
+
* be readable from RAM but not after a restart.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
|
|
136
|
+
for (const scope of bundleScopeChain(harness)) {
|
|
137
|
+
const entry = loadSession(name, now, scope);
|
|
138
|
+
if (entry)
|
|
139
|
+
return { entry, harness: scope };
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
128
143
|
/** Read one session blob by known name. Null when absent/expired/malformed. */
|
|
129
|
-
export function loadSession(name, now = Date.now(), harness =
|
|
144
|
+
export function loadSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
|
|
130
145
|
if (!shouldPersist())
|
|
131
146
|
return null;
|
|
132
147
|
try {
|
|
@@ -154,7 +169,7 @@ export function deleteBundleSessions(name) {
|
|
|
154
169
|
const scopedName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
|
|
155
170
|
if (scopedName !== name)
|
|
156
171
|
continue;
|
|
157
|
-
const harness = meta.harness ||
|
|
172
|
+
const harness = meta.harness || GLOBAL_HARNESS;
|
|
158
173
|
try {
|
|
159
174
|
deleteKeychainToken(key.includes(':') ? sessionBlobItem(name, harness) : `${SESSION_ITEM_PREFIX}${name}`);
|
|
160
175
|
}
|
|
@@ -164,7 +179,7 @@ export function deleteBundleSessions(name) {
|
|
|
164
179
|
writeIndex(next);
|
|
165
180
|
}
|
|
166
181
|
/** Delete one bundle's session blob and prune it from the index. */
|
|
167
|
-
export function deleteSession(name, harness =
|
|
182
|
+
export function deleteSession(name, harness = GLOBAL_HARNESS) {
|
|
168
183
|
if (!shouldPersist())
|
|
169
184
|
return;
|
|
170
185
|
try {
|
|
@@ -185,7 +200,7 @@ export function deleteAllSessions() {
|
|
|
185
200
|
const bundleName = name.includes(':') ? name.split(':').slice(1).join(':') : name;
|
|
186
201
|
try {
|
|
187
202
|
deleteKeychainToken(name.includes(':')
|
|
188
|
-
? sessionBlobItem(bundleName, meta.harness ||
|
|
203
|
+
? sessionBlobItem(bundleName, meta.harness || GLOBAL_HARNESS)
|
|
189
204
|
: `${SESSION_ITEM_PREFIX}${bundleName}`);
|
|
190
205
|
}
|
|
191
206
|
catch { /* keep going */ }
|
|
@@ -214,23 +229,61 @@ export function rehydrateSessions(now = Date.now()) {
|
|
|
214
229
|
try {
|
|
215
230
|
const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
|
|
216
231
|
const legacy = JSON.parse(raw);
|
|
217
|
-
setKeychainToken(sessionBlobItem(key,
|
|
232
|
+
setKeychainToken(sessionBlobItem(key, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
|
|
218
233
|
deleteKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
|
|
219
|
-
migratedIndex = upsertEntry(removeEntry(migratedIndex, key),
|
|
234
|
+
migratedIndex = upsertEntry(removeEntry(migratedIndex, key), `${GLOBAL_HARNESS}:${key}`, {
|
|
220
235
|
expiresAt: meta.expiresAt,
|
|
221
236
|
sleepPersist: meta.sleepPersist,
|
|
222
|
-
harness:
|
|
237
|
+
harness: GLOBAL_HARNESS,
|
|
223
238
|
});
|
|
224
239
|
}
|
|
225
240
|
catch { /* malformed/absent legacy entry is pruned below */ }
|
|
226
241
|
}
|
|
242
|
+
// Second source migration: `cli`-scoped grants predate the global scope. `cli`
|
|
243
|
+
// was never a harness — it was the default when no AGENTS_AGENT_NAME was set,
|
|
244
|
+
// i.e. "unlocked from a terminal for general use", which is exactly the global
|
|
245
|
+
// grant. Rewriting them means an unlock a user already paid Touch ID for keeps
|
|
246
|
+
// working after upgrade instead of silently becoming unreadable to every agent.
|
|
247
|
+
for (const [key, meta] of Object.entries(migratedIndex.bundles)) {
|
|
248
|
+
if (!key.startsWith('cli:'))
|
|
249
|
+
continue;
|
|
250
|
+
const bundleName = key.slice('cli:'.length);
|
|
251
|
+
const globalKey = `${GLOBAL_HARNESS}:${bundleName}`;
|
|
252
|
+
// A global grant for this bundle can already exist: the user re-runs
|
|
253
|
+
// `unlock` on the new code (which writes the global scope) before the
|
|
254
|
+
// broker restarts to run this migration. The existing global grant is the
|
|
255
|
+
// NEWER one, so the stale `cli` entry is discarded, never merged over it —
|
|
256
|
+
// overwriting would restore a superseded token and, if the stale entry had
|
|
257
|
+
// expired, hand its expiry to the fresh grant so the prune below deletes a
|
|
258
|
+
// valid unlock outright.
|
|
259
|
+
if (migratedIndex.bundles[globalKey]) {
|
|
260
|
+
try {
|
|
261
|
+
deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
|
|
262
|
+
}
|
|
263
|
+
catch { /* already gone */ }
|
|
264
|
+
migratedIndex = removeEntry(migratedIndex, key);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'));
|
|
269
|
+
const legacy = JSON.parse(raw);
|
|
270
|
+
setKeychainToken(sessionBlobItem(bundleName, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
|
|
271
|
+
deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
|
|
272
|
+
migratedIndex = upsertEntry(removeEntry(migratedIndex, key), globalKey, {
|
|
273
|
+
expiresAt: meta.expiresAt,
|
|
274
|
+
sleepPersist: meta.sleepPersist,
|
|
275
|
+
harness: GLOBAL_HARNESS,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
catch { /* malformed/absent entry is pruned below */ }
|
|
279
|
+
}
|
|
227
280
|
writeIndex(migratedIndex);
|
|
228
281
|
index.bundles = migratedIndex.bundles;
|
|
229
282
|
const { survivors, expiredNames } = pruneExpired(index, now);
|
|
230
283
|
for (const name of expiredNames) {
|
|
231
284
|
const meta = index.bundles[name];
|
|
232
285
|
try {
|
|
233
|
-
deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness ||
|
|
286
|
+
deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || GLOBAL_HARNESS));
|
|
234
287
|
}
|
|
235
288
|
catch { /* keep going */ }
|
|
236
289
|
}
|
|
@@ -239,7 +292,7 @@ export function rehydrateSessions(now = Date.now()) {
|
|
|
239
292
|
for (const name of selectRehydratable(survivors, now)) {
|
|
240
293
|
const meta = survivors.bundles[name];
|
|
241
294
|
const bundleName = name.split(':').slice(1).join(':');
|
|
242
|
-
const entry = loadSession(bundleName, now, meta.harness ||
|
|
295
|
+
const entry = loadSession(bundleName, now, meta.harness || GLOBAL_HARNESS);
|
|
243
296
|
if (entry)
|
|
244
297
|
out.push({ name: bundleName, entry });
|
|
245
298
|
}
|
|
@@ -259,7 +312,7 @@ export function pruneSessionsOnSleep() {
|
|
|
259
312
|
for (const name of deletedNames) {
|
|
260
313
|
const meta = readIndex().bundles[name];
|
|
261
314
|
try {
|
|
262
|
-
deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness ||
|
|
315
|
+
deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || GLOBAL_HARNESS));
|
|
263
316
|
}
|
|
264
317
|
catch { /* keep going */ }
|
|
265
318
|
}
|
|
@@ -189,6 +189,13 @@ export declare function agentKindFromComm(commRaw: string): string | undefined;
|
|
|
189
189
|
* the start time can't be read, we keep the existence answer.
|
|
190
190
|
*/
|
|
191
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;
|
|
192
199
|
/**
|
|
193
200
|
* Pick a Claude transcript file within a project dir.
|
|
194
201
|
*
|
|
@@ -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';
|
|
@@ -67,7 +67,6 @@ export function activeStatusFromCloudStatus(status) {
|
|
|
67
67
|
return 'queued';
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
const HOME = os.homedir();
|
|
71
70
|
const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
|
|
72
71
|
/**
|
|
73
72
|
* A process is classified `running` if its session file was touched in the
|
|
@@ -217,9 +216,45 @@ function claudeProjectDirName(cwd) {
|
|
|
217
216
|
* Locate the active Claude session file for a process. If we know the session
|
|
218
217
|
* UUID (from terminal env or team parent), prefer the exact match. Otherwise
|
|
219
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.
|
|
220
230
|
*/
|
|
221
231
|
function findClaudeSessionFile(cwd, sessionId) {
|
|
222
|
-
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;
|
|
223
258
|
}
|
|
224
259
|
/**
|
|
225
260
|
* Pick a Claude transcript file within a project dir.
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface SessionRow {
|
|
|
40
40
|
worktree_slug: string | null;
|
|
41
41
|
ticket_id: string | null;
|
|
42
42
|
plan: string | null;
|
|
43
|
+
machine: string | null;
|
|
43
44
|
todos: string | null;
|
|
44
45
|
recent_directories_touched: string | null;
|
|
45
46
|
linear_project: string | null;
|
|
@@ -248,6 +249,42 @@ export interface UsageRollupRow {
|
|
|
248
249
|
}
|
|
249
250
|
/** What to group a usage rollup by. */
|
|
250
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[];
|
|
251
288
|
/**
|
|
252
289
|
* Aggregate cost / duration / tokens across sessions, grouped by agent,
|
|
253
290
|
* project, or calendar day. Honors the same filter shape as querySessions
|
package/dist/lib/session/db.js
CHANGED
|
@@ -12,10 +12,11 @@ import Database from '../sqlite.js';
|
|
|
12
12
|
import { parseSession } from './parse.js';
|
|
13
13
|
import { extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
|
|
14
14
|
import { getSessionsDir, getSessionsDbPath } from '../state.js';
|
|
15
|
+
import { machineForSessionFile } from './origin-machine.js';
|
|
15
16
|
const SESSIONS_DIR = getSessionsDir();
|
|
16
17
|
const DB_PATH = getSessionsDbPath();
|
|
17
18
|
/** Current schema version; bumped when migrations are added. */
|
|
18
|
-
const SCHEMA_VERSION =
|
|
19
|
+
const SCHEMA_VERSION = 18;
|
|
19
20
|
/**
|
|
20
21
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
21
22
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -72,6 +73,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
72
73
|
worktree_slug TEXT,
|
|
73
74
|
ticket_id TEXT,
|
|
74
75
|
plan TEXT,
|
|
76
|
+
machine TEXT,
|
|
75
77
|
todos TEXT,
|
|
76
78
|
recent_directories_touched TEXT,
|
|
77
79
|
linear_project TEXT,
|
|
@@ -82,6 +84,8 @@ CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
|
82
84
|
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
|
|
83
85
|
CREATE INDEX IF NOT EXISTS idx_sessions_file_path ON sessions(file_path);
|
|
84
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).
|
|
85
89
|
|
|
86
90
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
|
|
87
91
|
session_id UNINDEXED,
|
|
@@ -323,6 +327,7 @@ function migrateSchema(db, fromVersion) {
|
|
|
323
327
|
db.exec(`UPDATE sessions SET short_id = substr(id, 1, 8) WHERE short_id IS NULL OR short_id = ''`);
|
|
324
328
|
}
|
|
325
329
|
if (fromVersion < 17) {
|
|
330
|
+
// v16 → v17 (main): todos / recent dirs / linear project metadata.
|
|
326
331
|
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
327
332
|
if (!cols.some(c => c.name === 'todos'))
|
|
328
333
|
db.exec(`ALTER TABLE sessions ADD COLUMN todos TEXT`);
|
|
@@ -334,6 +339,27 @@ function migrateSchema(db, fromVersion) {
|
|
|
334
339
|
db.exec(`ALTER TABLE sessions ADD COLUMN linear_project_url TEXT`);
|
|
335
340
|
db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
|
|
336
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
|
+
}
|
|
337
363
|
}
|
|
338
364
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
339
365
|
export function getDB() {
|
|
@@ -367,6 +393,27 @@ export function getDB() {
|
|
|
367
393
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
|
|
368
394
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
|
|
369
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
|
+
}
|
|
370
417
|
// One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
|
|
371
418
|
// them anymore. Guarded by a meta flag so we only try once.
|
|
372
419
|
const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
|
|
@@ -675,7 +722,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
675
722
|
output_tokens, cost_usd, duration_ms,
|
|
676
723
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
677
724
|
pr_url, pr_number, worktree_slug, ticket_id, plan, todos,
|
|
678
|
-
recent_directories_touched, linear_project, linear_project_url
|
|
725
|
+
recent_directories_touched, linear_project, linear_project_url, machine
|
|
679
726
|
) VALUES (
|
|
680
727
|
@id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
|
|
681
728
|
@version, @account, @timestamp, @last_activity,
|
|
@@ -683,7 +730,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
683
730
|
@output_tokens, @cost_usd, @duration_ms,
|
|
684
731
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
685
732
|
@pr_url, @pr_number, @worktree_slug, @ticket_id, @plan, @todos,
|
|
686
|
-
@recent_directories_touched, @linear_project, @linear_project_url
|
|
733
|
+
@recent_directories_touched, @linear_project, @linear_project_url, @machine
|
|
687
734
|
)
|
|
688
735
|
ON CONFLICT(id) DO UPDATE SET
|
|
689
736
|
short_id = excluded.short_id,
|
|
@@ -732,7 +779,8 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
732
779
|
linear_project_url = CASE
|
|
733
780
|
WHEN excluded.ticket_id IS NOT sessions.ticket_id THEN excluded.linear_project_url
|
|
734
781
|
ELSE COALESCE(excluded.linear_project_url, sessions.linear_project_url)
|
|
735
|
-
END
|
|
782
|
+
END,
|
|
783
|
+
machine = excluded.machine
|
|
736
784
|
`);
|
|
737
785
|
function enrichCachedSessionMeta(meta) {
|
|
738
786
|
if (!meta.filePath)
|
|
@@ -778,6 +826,12 @@ function storedFtsLabel(readLabel, id) {
|
|
|
778
826
|
const row = readLabel.get(id);
|
|
779
827
|
return row?.label ?? '';
|
|
780
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
|
+
}
|
|
781
835
|
/**
|
|
782
836
|
* Upsert a session row and replace its FTS5 content in a single transaction.
|
|
783
837
|
* `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
|
|
@@ -821,6 +875,7 @@ export function upsertSession(meta, content, scan) {
|
|
|
821
875
|
recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
|
|
822
876
|
linear_project: meta.linearProject ?? null,
|
|
823
877
|
linear_project_url: meta.linearProjectUrl ?? null,
|
|
878
|
+
machine: resolveMachine(meta),
|
|
824
879
|
};
|
|
825
880
|
const txn = db.transaction(() => {
|
|
826
881
|
upsert.run(row);
|
|
@@ -931,6 +986,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
931
986
|
recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
|
|
932
987
|
linear_project: meta.linearProject ?? null,
|
|
933
988
|
linear_project_url: meta.linearProjectUrl ?? null,
|
|
989
|
+
machine: resolveMachine(meta),
|
|
934
990
|
});
|
|
935
991
|
delText.run(meta.id);
|
|
936
992
|
insText.run(meta.id,
|
|
@@ -1115,6 +1171,7 @@ function rowToMeta(row) {
|
|
|
1115
1171
|
recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
|
|
1116
1172
|
linearProject: row.linear_project ?? undefined,
|
|
1117
1173
|
linearProjectUrl: row.linear_project_url ?? undefined,
|
|
1174
|
+
machine: row.machine ?? undefined,
|
|
1118
1175
|
};
|
|
1119
1176
|
}
|
|
1120
1177
|
function parseJsonColumn(value) {
|
|
@@ -1287,6 +1344,57 @@ export function countSessions(options = {}) {
|
|
|
1287
1344
|
const row = db.prepare(sql).get(...params);
|
|
1288
1345
|
return row ? row.n : 0;
|
|
1289
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
|
+
}
|
|
1290
1398
|
/**
|
|
1291
1399
|
* Aggregate cost / duration / tokens across sessions, grouped by agent,
|
|
1292
1400
|
* project, or calendar day. Honors the same filter shape as querySessions
|