@phnx-labs/agents-cli 1.20.17 → 1.20.19
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 +19 -0
- package/README.md +1 -1
- package/dist/commands/budget.d.ts +14 -0
- package/dist/commands/budget.js +137 -0
- package/dist/commands/cost.d.ts +12 -0
- package/dist/commands/cost.js +139 -0
- package/dist/commands/exec.d.ts +20 -0
- package/dist/commands/exec.js +382 -5
- package/dist/commands/secrets.d.ts +15 -0
- package/dist/commands/secrets.js +343 -16
- package/dist/commands/sessions.js +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/budget/config.d.ts +9 -0
- package/dist/lib/budget/config.js +115 -0
- package/dist/lib/budget/enforce.d.ts +94 -0
- package/dist/lib/budget/enforce.js +151 -0
- package/dist/lib/budget/ledger.d.ts +61 -0
- package/dist/lib/budget/ledger.js +107 -0
- package/dist/lib/budget/preflight.d.ts +110 -0
- package/dist/lib/budget/preflight.js +200 -0
- package/dist/lib/checkpoint.d.ts +54 -0
- package/dist/lib/checkpoint.js +56 -0
- package/dist/lib/cloud/rush.js +18 -0
- package/dist/lib/exec.d.ts +36 -0
- package/dist/lib/exec.js +192 -4
- package/dist/lib/git.d.ts +18 -0
- package/dist/lib/git.js +67 -4
- package/dist/lib/loop.d.ts +145 -0
- package/dist/lib/loop.js +330 -0
- package/dist/lib/mcp.d.ts +7 -0
- package/dist/lib/mcp.js +24 -0
- package/dist/lib/models.d.ts +11 -0
- package/dist/lib/models.js +21 -0
- package/dist/lib/plugins.js +5 -2
- package/dist/lib/pricing/cost.d.ts +46 -0
- package/dist/lib/pricing/cost.js +71 -0
- package/dist/lib/pricing/index.d.ts +8 -0
- package/dist/lib/pricing/index.js +8 -0
- package/dist/lib/pricing/prices.json +138 -0
- package/dist/lib/pricing/table.d.ts +17 -0
- package/dist/lib/pricing/table.js +73 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +147 -0
- package/dist/lib/secrets/agent.js +500 -0
- package/dist/lib/secrets/bundles.d.ts +58 -7
- package/dist/lib/secrets/bundles.js +264 -75
- package/dist/lib/secrets/filestore.d.ts +82 -0
- package/dist/lib/secrets/filestore.js +295 -0
- package/dist/lib/secrets/linux.d.ts +6 -24
- package/dist/lib/secrets/linux.js +22 -265
- package/dist/lib/session/db.d.ts +40 -0
- package/dist/lib/session/db.js +84 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +126 -2
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/types.d.ts +4 -0
- package/dist/lib/teams/agents.d.ts +32 -0
- package/dist/lib/teams/agents.js +66 -3
- package/dist/lib/teams/api.js +20 -0
- package/dist/lib/teams/parsers.js +16 -4
- package/dist/lib/types.d.ts +48 -0
- package/dist/lib/workflows.d.ts +56 -0
- package/dist/lib/workflows.js +72 -5
- package/package.json +2 -1
|
@@ -7,24 +7,21 @@
|
|
|
7
7
|
* (common on server-class Linux — no graphical login means the keyring
|
|
8
8
|
* passphrase never enters the daemon, so `secret-tool store` fails with
|
|
9
9
|
* "Cannot create an item in a locked collection"), we transparently switch
|
|
10
|
-
* to
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* prompt. The decision is cached per process; one stderr line is emitted
|
|
14
|
-
* the first time the fallback activates.
|
|
10
|
+
* to the AES-256-GCM encrypted-file store in ./filestore.ts. The decision is
|
|
11
|
+
* cached per process; one stderr line is emitted the first time the fallback
|
|
12
|
+
* activates.
|
|
15
13
|
*
|
|
16
14
|
* Secrets stored via secret-tool use:
|
|
17
15
|
* service = "agents-cli"
|
|
18
16
|
* account = username
|
|
19
17
|
* item = the secret identifier
|
|
20
|
-
*
|
|
21
|
-
* File-fallback layout: one `<item>.enc` JSON file per item, mode 0600.
|
|
22
18
|
*/
|
|
23
|
-
import { spawnSync
|
|
24
|
-
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
25
|
-
import * as fs from 'fs';
|
|
19
|
+
import { spawnSync } from 'child_process';
|
|
26
20
|
import * as os from 'os';
|
|
27
|
-
import
|
|
21
|
+
import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
|
|
22
|
+
// Re-exported so existing importers (and tests) can keep reaching these via
|
|
23
|
+
// './linux.js'. The implementations live in ./filestore.ts.
|
|
24
|
+
export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
|
|
28
25
|
const SERVICE = 'agents-cli';
|
|
29
26
|
// ---------- secret-tool availability ----------
|
|
30
27
|
function secretToolAvailable() {
|
|
@@ -38,12 +35,6 @@ let isAvailable = false;
|
|
|
38
35
|
// ---------- file fallback state ----------
|
|
39
36
|
let useFileFallback = false;
|
|
40
37
|
let warnedFallback = false;
|
|
41
|
-
let warnedAutoPassphrase = false;
|
|
42
|
-
let fileDirOverride = null;
|
|
43
|
-
let cachedPassphrase = null;
|
|
44
|
-
function fileDir() {
|
|
45
|
-
return fileDirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'secrets');
|
|
46
|
-
}
|
|
47
38
|
function activateFileFallback() {
|
|
48
39
|
if (useFileFallback)
|
|
49
40
|
return;
|
|
@@ -57,18 +48,6 @@ function isLockedCollectionError(stderr) {
|
|
|
57
48
|
return /locked collection/i.test(stderr) ||
|
|
58
49
|
/Prompt was dismissed/i.test(stderr);
|
|
59
50
|
}
|
|
60
|
-
/** True if the fallback dir has any committed encrypted items. Means an
|
|
61
|
-
* earlier process (this one or another) already routed writes to the file
|
|
62
|
-
* store, so this process must keep reading/writing from the same store —
|
|
63
|
-
* otherwise `list` / `get` / `has` would silently miss them. */
|
|
64
|
-
function fileFallbackPreviouslyActivated() {
|
|
65
|
-
try {
|
|
66
|
-
return fs.readdirSync(fileDir()).some((e) => e.endsWith('.enc'));
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
51
|
/**
|
|
73
52
|
* Decide which backend a given op should use. Activates file fallback if
|
|
74
53
|
* `secret-tool` is missing and `AGENTS_SECRETS_PASSPHRASE` is set, OR if a
|
|
@@ -79,7 +58,7 @@ function fileFallbackPreviouslyActivated() {
|
|
|
79
58
|
function preflight() {
|
|
80
59
|
if (useFileFallback)
|
|
81
60
|
return 'file';
|
|
82
|
-
if (
|
|
61
|
+
if (fileStoreHasItems()) {
|
|
83
62
|
activateFileFallback();
|
|
84
63
|
return 'file';
|
|
85
64
|
}
|
|
@@ -107,233 +86,12 @@ function preflight() {
|
|
|
107
86
|
}
|
|
108
87
|
return 'secret-tool';
|
|
109
88
|
}
|
|
110
|
-
// ---------- passphrase ----------
|
|
111
|
-
function readPassphraseFromTty() {
|
|
112
|
-
const fd = fs.openSync('/dev/tty', 'r+');
|
|
113
|
-
let echoDisabled = false;
|
|
114
|
-
try {
|
|
115
|
-
fs.writeSync(fd, 'Enter AGENTS_SECRETS_PASSPHRASE: ');
|
|
116
|
-
try {
|
|
117
|
-
execSync('stty -echo < /dev/tty', { stdio: 'ignore' });
|
|
118
|
-
echoDisabled = true;
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
// stty not available — fall through; passphrase will echo. Better
|
|
122
|
-
// than refusing to function.
|
|
123
|
-
}
|
|
124
|
-
let pass = '';
|
|
125
|
-
const buf = Buffer.alloc(1);
|
|
126
|
-
while (true) {
|
|
127
|
-
const n = fs.readSync(fd, buf, 0, 1, null);
|
|
128
|
-
if (n === 0)
|
|
129
|
-
break;
|
|
130
|
-
const ch = buf.toString('utf8', 0, n);
|
|
131
|
-
if (ch === '\n' || ch === '\r')
|
|
132
|
-
break;
|
|
133
|
-
pass += ch;
|
|
134
|
-
}
|
|
135
|
-
return pass;
|
|
136
|
-
}
|
|
137
|
-
finally {
|
|
138
|
-
if (echoDisabled) {
|
|
139
|
-
try {
|
|
140
|
-
execSync('stty echo < /dev/tty', { stdio: 'ignore' });
|
|
141
|
-
}
|
|
142
|
-
catch { /* best effort */ }
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
fs.writeSync(fd, '\n');
|
|
146
|
-
}
|
|
147
|
-
catch { /* best effort */ }
|
|
148
|
-
fs.closeSync(fd);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
/** Path of the auto-provisioned machine-local passphrase. Lives alongside the
|
|
152
|
-
* encrypted items but is never itself an item (no `.enc` suffix, so it's
|
|
153
|
-
* excluded from list/has/get and from fileFallbackPreviouslyActivated). */
|
|
154
|
-
function passphraseFilePath() {
|
|
155
|
-
return path.join(fileDir(), '.passphrase');
|
|
156
|
-
}
|
|
157
|
-
/** True if a machine-local passphrase has already been provisioned. */
|
|
158
|
-
function machinePassphraseExists() {
|
|
159
|
-
try {
|
|
160
|
-
return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
function readMachinePassphrase() {
|
|
167
|
-
try {
|
|
168
|
-
const p = fs.readFileSync(passphraseFilePath(), 'utf8').trim();
|
|
169
|
-
return p.length > 0 ? p : null;
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Provision (or read back) a stable machine-local passphrase for the encrypted
|
|
177
|
-
* file store, so `agents secrets` works out of the box on a headless box where
|
|
178
|
-
* the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
|
|
179
|
-
*
|
|
180
|
-
* Security model: this is encryption-at-rest with the key held in a 0600 file —
|
|
181
|
-
* the same posture as an SSH private key, and identical to the common
|
|
182
|
-
* "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround. The
|
|
183
|
-
* keyring (key in a daemon's locked memory) is stronger but is unavailable
|
|
184
|
-
* without a graphical/unlocked session. For an off-disk key, set
|
|
185
|
-
* AGENTS_SECRETS_PASSPHRASE (it always takes precedence) or unlock the keyring.
|
|
186
|
-
*/
|
|
187
|
-
function provisionMachinePassphrase() {
|
|
188
|
-
const existing = readMachinePassphrase();
|
|
189
|
-
if (existing)
|
|
190
|
-
return existing;
|
|
191
|
-
ensureFileDir();
|
|
192
|
-
const generated = randomBytes(32).toString('base64');
|
|
193
|
-
const fp = passphraseFilePath();
|
|
194
|
-
try {
|
|
195
|
-
// wx: fail if a concurrent process created it first (then we read theirs).
|
|
196
|
-
fs.writeFileSync(fp, generated, { mode: 0o600, flag: 'wx' });
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
const raced = readMachinePassphrase();
|
|
200
|
-
if (raced)
|
|
201
|
-
return raced;
|
|
202
|
-
throw new Error(`Failed to provision machine-local passphrase at ${fp}.`);
|
|
203
|
-
}
|
|
204
|
-
if (!warnedAutoPassphrase) {
|
|
205
|
-
warnedAutoPassphrase = true;
|
|
206
|
-
process.stderr.write(`[agents] keyring locked and no AGENTS_SECRETS_PASSPHRASE set; provisioned a ` +
|
|
207
|
-
`machine-local passphrase at ${fp} (mode 0600). Set AGENTS_SECRETS_PASSPHRASE ` +
|
|
208
|
-
`for a key held off disk.\n`);
|
|
209
|
-
}
|
|
210
|
-
return generated;
|
|
211
|
-
}
|
|
212
|
-
function getPassphrase() {
|
|
213
|
-
if (cachedPassphrase !== null)
|
|
214
|
-
return cachedPassphrase;
|
|
215
|
-
const env = process.env.AGENTS_SECRETS_PASSPHRASE;
|
|
216
|
-
if (env && env.length > 0) {
|
|
217
|
-
cachedPassphrase = env;
|
|
218
|
-
return env;
|
|
219
|
-
}
|
|
220
|
-
// A previously-provisioned machine-local passphrase is this machine's stable
|
|
221
|
-
// file-store key — prefer it for both interactive and headless runs so they
|
|
222
|
-
// always agree (a TTY run won't re-prompt once the file exists).
|
|
223
|
-
const onDisk = readMachinePassphrase();
|
|
224
|
-
if (onDisk) {
|
|
225
|
-
cachedPassphrase = onDisk;
|
|
226
|
-
return onDisk;
|
|
227
|
-
}
|
|
228
|
-
// First run, no env, no provisioned key: prompt when interactive, otherwise
|
|
229
|
-
// (headless — the reported bug) auto-provision instead of hard-failing.
|
|
230
|
-
if (process.stdin.isTTY) {
|
|
231
|
-
const p = readPassphraseFromTty();
|
|
232
|
-
if (!p)
|
|
233
|
-
throw new Error('No passphrase entered.');
|
|
234
|
-
cachedPassphrase = p;
|
|
235
|
-
return p;
|
|
236
|
-
}
|
|
237
|
-
cachedPassphrase = provisionMachinePassphrase();
|
|
238
|
-
return cachedPassphrase;
|
|
239
|
-
}
|
|
240
|
-
function deriveKey(passphrase, salt) {
|
|
241
|
-
return scryptSync(passphrase, salt, 32);
|
|
242
|
-
}
|
|
243
|
-
/** Encrypt plaintext under a passphrase using AES-256-GCM with a random
|
|
244
|
-
* scrypt salt and a random 96-bit IV. Exported for tests. */
|
|
245
|
-
export function encryptForFallback(plaintext, passphrase) {
|
|
246
|
-
const salt = randomBytes(16);
|
|
247
|
-
const iv = randomBytes(12);
|
|
248
|
-
const key = deriveKey(passphrase, salt);
|
|
249
|
-
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
250
|
-
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
251
|
-
return {
|
|
252
|
-
salt: salt.toString('hex'),
|
|
253
|
-
iv: iv.toString('hex'),
|
|
254
|
-
authTag: cipher.getAuthTag().toString('hex'),
|
|
255
|
-
ciphertext: ciphertext.toString('hex'),
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
/** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
|
|
259
|
-
* ciphertext (auth-tag mismatch). Exported for tests. */
|
|
260
|
-
export function decryptForFallback(enc, passphrase) {
|
|
261
|
-
const salt = Buffer.from(enc.salt, 'hex');
|
|
262
|
-
const iv = Buffer.from(enc.iv, 'hex');
|
|
263
|
-
const authTag = Buffer.from(enc.authTag, 'hex');
|
|
264
|
-
const ciphertext = Buffer.from(enc.ciphertext, 'hex');
|
|
265
|
-
const key = deriveKey(passphrase, salt);
|
|
266
|
-
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
267
|
-
decipher.setAuthTag(authTag);
|
|
268
|
-
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
269
|
-
return plaintext.toString('utf8');
|
|
270
|
-
}
|
|
271
|
-
// ---------- file backend ----------
|
|
272
|
-
function fileFor(item) {
|
|
273
|
-
return path.join(fileDir(), `${item}.enc`);
|
|
274
|
-
}
|
|
275
|
-
function ensureFileDir() {
|
|
276
|
-
fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
|
|
277
|
-
}
|
|
278
|
-
function fileHas(item) {
|
|
279
|
-
return fs.existsSync(fileFor(item));
|
|
280
|
-
}
|
|
281
|
-
function fileGet(item) {
|
|
282
|
-
const fp = fileFor(item);
|
|
283
|
-
if (!fs.existsSync(fp)) {
|
|
284
|
-
throw new Error(`Secret '${item}' not found in encrypted store.`);
|
|
285
|
-
}
|
|
286
|
-
const raw = fs.readFileSync(fp, 'utf8');
|
|
287
|
-
let parsed;
|
|
288
|
-
try {
|
|
289
|
-
parsed = JSON.parse(raw);
|
|
290
|
-
}
|
|
291
|
-
catch {
|
|
292
|
-
throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
|
|
293
|
-
}
|
|
294
|
-
try {
|
|
295
|
-
return decryptForFallback(parsed, getPassphrase());
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
function fileSet(item, value) {
|
|
302
|
-
ensureFileDir();
|
|
303
|
-
const enc = encryptForFallback(value, getPassphrase());
|
|
304
|
-
fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
|
|
305
|
-
}
|
|
306
|
-
function fileDelete(item) {
|
|
307
|
-
const fp = fileFor(item);
|
|
308
|
-
if (!fs.existsSync(fp))
|
|
309
|
-
return true; // idempotent, matches secret-tool clear
|
|
310
|
-
fs.unlinkSync(fp);
|
|
311
|
-
return true;
|
|
312
|
-
}
|
|
313
|
-
function fileList(prefix) {
|
|
314
|
-
const dir = fileDir();
|
|
315
|
-
if (!fs.existsSync(dir))
|
|
316
|
-
return [];
|
|
317
|
-
return fs.readdirSync(dir)
|
|
318
|
-
.filter((f) => f.endsWith('.enc'))
|
|
319
|
-
.map((f) => f.slice(0, -'.enc'.length))
|
|
320
|
-
.filter((name) => name.startsWith(prefix));
|
|
321
|
-
}
|
|
322
|
-
/** File-only KeychainBackend (exported for tests; the public surface uses
|
|
323
|
-
* the secret-tool-with-fallback `linuxBackend` below). */
|
|
324
|
-
export const fileBackend = {
|
|
325
|
-
has: fileHas,
|
|
326
|
-
get: fileGet,
|
|
327
|
-
set: fileSet,
|
|
328
|
-
delete: fileDelete,
|
|
329
|
-
list: fileList,
|
|
330
|
-
};
|
|
331
89
|
// ---------- secret-tool ops with fallback ----------
|
|
332
90
|
/** secret-tool lookup attributes:
|
|
333
91
|
* service=agents-cli account=<user> item=<itemName> */
|
|
334
92
|
export function hasSecretToolToken(item) {
|
|
335
93
|
if (preflight() === 'file')
|
|
336
|
-
return
|
|
94
|
+
return fileStore.has(item);
|
|
337
95
|
const user = os.userInfo().username;
|
|
338
96
|
const result = spawnSync('secret-tool', [
|
|
339
97
|
'lookup',
|
|
@@ -347,13 +105,13 @@ export function hasSecretToolToken(item) {
|
|
|
347
105
|
const stderr = result.stderr?.toString() ?? '';
|
|
348
106
|
if (isLockedCollectionError(stderr)) {
|
|
349
107
|
activateFileFallback();
|
|
350
|
-
return
|
|
108
|
+
return fileStore.has(item);
|
|
351
109
|
}
|
|
352
110
|
return false;
|
|
353
111
|
}
|
|
354
112
|
export function getSecretToolToken(item) {
|
|
355
113
|
if (preflight() === 'file')
|
|
356
|
-
return
|
|
114
|
+
return fileStore.get(item);
|
|
357
115
|
const user = os.userInfo().username;
|
|
358
116
|
const result = spawnSync('secret-tool', [
|
|
359
117
|
'lookup',
|
|
@@ -370,7 +128,7 @@ export function getSecretToolToken(item) {
|
|
|
370
128
|
const stderr = result.stderr?.toString() ?? '';
|
|
371
129
|
if (isLockedCollectionError(stderr)) {
|
|
372
130
|
activateFileFallback();
|
|
373
|
-
return
|
|
131
|
+
return fileStore.get(item);
|
|
374
132
|
}
|
|
375
133
|
throw new Error(`Secret '${item}' not found in keyring.`);
|
|
376
134
|
}
|
|
@@ -378,7 +136,7 @@ export function setSecretToolToken(item, value) {
|
|
|
378
136
|
if (!value || !value.trim())
|
|
379
137
|
throw new Error('Secret value is empty.');
|
|
380
138
|
if (preflight() === 'file')
|
|
381
|
-
return
|
|
139
|
+
return fileStore.set(item, value);
|
|
382
140
|
const user = os.userInfo().username;
|
|
383
141
|
const label = `agents-cli: ${item}`;
|
|
384
142
|
const result = spawnSync('secret-tool', [
|
|
@@ -393,7 +151,7 @@ export function setSecretToolToken(item, value) {
|
|
|
393
151
|
const stderr = result.stderr?.toString().trim() ?? '';
|
|
394
152
|
if (isLockedCollectionError(stderr)) {
|
|
395
153
|
activateFileFallback();
|
|
396
|
-
|
|
154
|
+
fileStore.set(item, value);
|
|
397
155
|
return;
|
|
398
156
|
}
|
|
399
157
|
throw new Error(`Failed to store secret '${item}': ${stderr || 'unknown error'}\n` +
|
|
@@ -402,7 +160,7 @@ export function setSecretToolToken(item, value) {
|
|
|
402
160
|
}
|
|
403
161
|
export function deleteSecretToolToken(item) {
|
|
404
162
|
if (preflight() === 'file')
|
|
405
|
-
return
|
|
163
|
+
return fileStore.delete(item);
|
|
406
164
|
const user = os.userInfo().username;
|
|
407
165
|
const result = spawnSync('secret-tool', [
|
|
408
166
|
'clear',
|
|
@@ -415,7 +173,7 @@ export function deleteSecretToolToken(item) {
|
|
|
415
173
|
const stderr = result.stderr?.toString() ?? '';
|
|
416
174
|
if (isLockedCollectionError(stderr)) {
|
|
417
175
|
activateFileFallback();
|
|
418
|
-
return
|
|
176
|
+
return fileStore.delete(item);
|
|
419
177
|
}
|
|
420
178
|
// secret-tool clear returns 0 whether the item existed or not.
|
|
421
179
|
// A non-zero exit that isn't a locked-collection error is a real failure;
|
|
@@ -456,7 +214,7 @@ export function parseSecretToolItems(output, prefix) {
|
|
|
456
214
|
*/
|
|
457
215
|
export function listSecretToolItems(prefix) {
|
|
458
216
|
if (preflight() === 'file')
|
|
459
|
-
return
|
|
217
|
+
return fileStore.list(prefix);
|
|
460
218
|
const result = spawnSync('secret-tool', [
|
|
461
219
|
'search',
|
|
462
220
|
'--all',
|
|
@@ -466,7 +224,7 @@ export function listSecretToolItems(prefix) {
|
|
|
466
224
|
const stderr = result.stderr?.toString() ?? '';
|
|
467
225
|
if (isLockedCollectionError(stderr)) {
|
|
468
226
|
activateFileFallback();
|
|
469
|
-
return
|
|
227
|
+
return fileStore.list(prefix);
|
|
470
228
|
}
|
|
471
229
|
return [];
|
|
472
230
|
}
|
|
@@ -495,13 +253,12 @@ export const linuxBackend = {
|
|
|
495
253
|
},
|
|
496
254
|
};
|
|
497
255
|
/** Test-only: reset module state so independent test cases don't bleed
|
|
498
|
-
* passphrase / fallback decisions across each other.
|
|
256
|
+
* passphrase / fallback decisions across each other. File-store state (file
|
|
257
|
+
* dir + cached passphrase) lives in ./filestore.ts and is reset there. */
|
|
499
258
|
export function _resetForTest(opts = {}) {
|
|
500
|
-
|
|
259
|
+
_resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
|
|
501
260
|
useFileFallback = opts.forceFileFallback ?? false;
|
|
502
261
|
warnedFallback = false;
|
|
503
|
-
warnedAutoPassphrase = false;
|
|
504
|
-
cachedPassphrase = opts.passphrase ?? null;
|
|
505
262
|
checkedAvailability = false;
|
|
506
263
|
isAvailable = false;
|
|
507
264
|
}
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface SessionRow {
|
|
|
23
23
|
label: string | null;
|
|
24
24
|
message_count: number | null;
|
|
25
25
|
token_count: number | null;
|
|
26
|
+
cost_usd: number | null;
|
|
27
|
+
duration_ms: number | null;
|
|
26
28
|
file_path: string;
|
|
27
29
|
file_mtime_ms: number | null;
|
|
28
30
|
file_size: number | null;
|
|
@@ -50,6 +52,12 @@ export interface QueryOptions {
|
|
|
50
52
|
excludeTeamOrigin?: boolean;
|
|
51
53
|
/** Keep only team-origin rows (for hidden-count queries). */
|
|
52
54
|
onlyTeamOrigin?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Column to order by, all descending. 'timestamp' (default) sorts newest
|
|
57
|
+
* first; 'cost' and 'duration' put the priciest / longest sessions on top,
|
|
58
|
+
* with NULLs sorted last so unpriced rows never crowd out real data.
|
|
59
|
+
*/
|
|
60
|
+
sortBy?: 'timestamp' | 'cost' | 'duration';
|
|
53
61
|
}
|
|
54
62
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
55
63
|
export declare function getDB(): Database.Database;
|
|
@@ -114,6 +122,38 @@ export declare function syncLabels(labelMap: Map<string, string | null>): number
|
|
|
114
122
|
export declare function querySessions(options?: QueryOptions): SessionMeta[];
|
|
115
123
|
/** Count sessions matching the given filter options. */
|
|
116
124
|
export declare function countSessions(options?: QueryOptions): number;
|
|
125
|
+
/** One grouped row in a cost/duration rollup. */
|
|
126
|
+
export interface UsageRollupRow {
|
|
127
|
+
/** Grouping key value: the agent id, project name, or ISO date (YYYY-MM-DD). */
|
|
128
|
+
key: string;
|
|
129
|
+
costUsd: number;
|
|
130
|
+
durationMs: number;
|
|
131
|
+
sessionCount: number;
|
|
132
|
+
tokenCount: number;
|
|
133
|
+
}
|
|
134
|
+
/** What to group a usage rollup by. */
|
|
135
|
+
export type UsageRollupGroup = 'agent' | 'project' | 'day';
|
|
136
|
+
/**
|
|
137
|
+
* Aggregate cost / duration / tokens across sessions, grouped by agent,
|
|
138
|
+
* project, or calendar day. Honors the same filter shape as querySessions
|
|
139
|
+
* (agent, since/until, team-origin) so `agents cost --since 7d --by day`
|
|
140
|
+
* lines up with what `agents sessions` would list. Ordered by cost desc.
|
|
141
|
+
*/
|
|
142
|
+
export declare function queryUsageRollup(options: QueryOptions & {
|
|
143
|
+
groupBy: UsageRollupGroup;
|
|
144
|
+
}): UsageRollupRow[];
|
|
145
|
+
/** A session with its cost, for the top-N-by-cost listing. */
|
|
146
|
+
export interface TopCostSession {
|
|
147
|
+
meta: SessionMeta;
|
|
148
|
+
costUsd: number;
|
|
149
|
+
durationMs: number;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Return the N most expensive sessions (cost_usd DESC, NULLs excluded),
|
|
153
|
+
* honoring the same filter shape as querySessions. Drops rows whose JSONL
|
|
154
|
+
* vanished, mirroring querySessions' liveness filter.
|
|
155
|
+
*/
|
|
156
|
+
export declare function topSessionsByCost(n: number, options?: QueryOptions): TopCostSession[];
|
|
117
157
|
/** Return the set of all file paths currently tracked in the sessions table. */
|
|
118
158
|
export declare function getAllFilePaths(): Set<string>;
|
|
119
159
|
/** Look up sessions by their source file paths. */
|
package/dist/lib/session/db.js
CHANGED
|
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
|
|
|
13
13
|
const SESSIONS_DIR = getSessionsDir();
|
|
14
14
|
const DB_PATH = getSessionsDbPath();
|
|
15
15
|
/** Current schema version; bumped when migrations are added. */
|
|
16
|
-
const SCHEMA_VERSION =
|
|
16
|
+
const SCHEMA_VERSION = 6;
|
|
17
17
|
/**
|
|
18
18
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
19
19
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -53,6 +53,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
53
53
|
label TEXT,
|
|
54
54
|
message_count INTEGER,
|
|
55
55
|
token_count INTEGER,
|
|
56
|
+
cost_usd REAL,
|
|
57
|
+
duration_ms INTEGER,
|
|
56
58
|
file_path TEXT NOT NULL,
|
|
57
59
|
file_mtime_ms INTEGER,
|
|
58
60
|
file_size INTEGER,
|
|
@@ -141,6 +143,19 @@ function migrateSchema(db, fromVersion) {
|
|
|
141
143
|
// repopulate under canonical keys.
|
|
142
144
|
db.exec(`DELETE FROM scan_ledger;`);
|
|
143
145
|
}
|
|
146
|
+
if (fromVersion < 6) {
|
|
147
|
+
// v5 → v6: cost ($) and wall-clock duration are now computed at scan time
|
|
148
|
+
// from raw per-model token usage. Add the columns and force a full rescan
|
|
149
|
+
// so every existing session gets its cost_usd / duration_ms populated.
|
|
150
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
151
|
+
if (!cols.some(c => c.name === 'cost_usd')) {
|
|
152
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN cost_usd REAL`);
|
|
153
|
+
}
|
|
154
|
+
if (!cols.some(c => c.name === 'duration_ms')) {
|
|
155
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN duration_ms INTEGER`);
|
|
156
|
+
}
|
|
157
|
+
db.exec(`DELETE FROM scan_ledger;`);
|
|
158
|
+
}
|
|
144
159
|
}
|
|
145
160
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
146
161
|
export function getDB() {
|
|
@@ -350,10 +365,12 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
350
365
|
INSERT INTO sessions (
|
|
351
366
|
id, short_id, agent, version, account, timestamp,
|
|
352
367
|
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
368
|
+
cost_usd, duration_ms,
|
|
353
369
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin
|
|
354
370
|
) VALUES (
|
|
355
371
|
@id, @short_id, @agent, @version, @account, @timestamp,
|
|
356
372
|
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
373
|
+
@cost_usd, @duration_ms,
|
|
357
374
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin
|
|
358
375
|
)
|
|
359
376
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -369,6 +386,8 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
369
386
|
label = excluded.label,
|
|
370
387
|
message_count = excluded.message_count,
|
|
371
388
|
token_count = excluded.token_count,
|
|
389
|
+
cost_usd = excluded.cost_usd,
|
|
390
|
+
duration_ms = excluded.duration_ms,
|
|
372
391
|
file_path = excluded.file_path,
|
|
373
392
|
file_mtime_ms = excluded.file_mtime_ms,
|
|
374
393
|
file_size = excluded.file_size,
|
|
@@ -409,6 +428,8 @@ export function upsertSession(meta, content, scan) {
|
|
|
409
428
|
label: meta.label ?? null,
|
|
410
429
|
message_count: meta.messageCount ?? null,
|
|
411
430
|
token_count: meta.tokenCount ?? null,
|
|
431
|
+
cost_usd: meta.costUsd ?? null,
|
|
432
|
+
duration_ms: meta.durationMs ?? null,
|
|
412
433
|
file_path: meta.filePath,
|
|
413
434
|
file_mtime_ms: scan?.fileMtimeMs ?? null,
|
|
414
435
|
file_size: scan?.fileSize ?? null,
|
|
@@ -482,6 +503,8 @@ export function upsertSessionsBatch(entries) {
|
|
|
482
503
|
label: meta.label ?? null,
|
|
483
504
|
message_count: meta.messageCount ?? null,
|
|
484
505
|
token_count: meta.tokenCount ?? null,
|
|
506
|
+
cost_usd: meta.costUsd ?? null,
|
|
507
|
+
duration_ms: meta.durationMs ?? null,
|
|
485
508
|
file_path: meta.filePath,
|
|
486
509
|
file_mtime_ms: scan?.fileMtimeMs ?? null,
|
|
487
510
|
file_size: scan?.fileSize ?? null,
|
|
@@ -548,6 +571,8 @@ function rowToMeta(row) {
|
|
|
548
571
|
gitBranch: row.git_branch ?? undefined,
|
|
549
572
|
messageCount: row.message_count ?? undefined,
|
|
550
573
|
tokenCount: row.token_count ?? undefined,
|
|
574
|
+
costUsd: row.cost_usd ?? undefined,
|
|
575
|
+
durationMs: row.duration_ms ?? undefined,
|
|
551
576
|
version: row.version ?? undefined,
|
|
552
577
|
account: row.account ?? undefined,
|
|
553
578
|
topic: row.topic ?? undefined,
|
|
@@ -611,7 +636,14 @@ export function querySessions(options = {}) {
|
|
|
611
636
|
const limitClause = options.limit
|
|
612
637
|
? `LIMIT ${Math.max(1, Math.floor(options.limit)) + 16}`
|
|
613
638
|
: '';
|
|
614
|
-
|
|
639
|
+
// NULLs last so unpriced / duration-less rows never crowd out real data when
|
|
640
|
+
// sorting by cost or duration. timestamp is never null (NOT NULL column).
|
|
641
|
+
const orderClause = options.sortBy === 'cost'
|
|
642
|
+
? 'ORDER BY cost_usd IS NULL, cost_usd DESC, timestamp DESC'
|
|
643
|
+
: options.sortBy === 'duration'
|
|
644
|
+
? 'ORDER BY duration_ms IS NULL, duration_ms DESC, timestamp DESC'
|
|
645
|
+
: 'ORDER BY timestamp DESC';
|
|
646
|
+
const sql = `SELECT * FROM sessions ${clause} ${orderClause} ${limitClause}`;
|
|
615
647
|
const rows = db.prepare(sql).all(...params);
|
|
616
648
|
// Belt-and-suspenders: drop rows whose JSONL no longer exists on disk. The
|
|
617
649
|
// authoritative fix is to keep file_path in sync (see updateSessionFilePaths
|
|
@@ -631,6 +663,56 @@ export function countSessions(options = {}) {
|
|
|
631
663
|
const row = db.prepare(sql).get(...params);
|
|
632
664
|
return row ? row.n : 0;
|
|
633
665
|
}
|
|
666
|
+
/**
|
|
667
|
+
* Aggregate cost / duration / tokens across sessions, grouped by agent,
|
|
668
|
+
* project, or calendar day. Honors the same filter shape as querySessions
|
|
669
|
+
* (agent, since/until, team-origin) so `agents cost --since 7d --by day`
|
|
670
|
+
* lines up with what `agents sessions` would list. Ordered by cost desc.
|
|
671
|
+
*/
|
|
672
|
+
export function queryUsageRollup(options) {
|
|
673
|
+
const db = getDB();
|
|
674
|
+
const { clause, params } = buildSessionWhere(options);
|
|
675
|
+
const keyExpr = options.groupBy === 'agent'
|
|
676
|
+
? 'agent'
|
|
677
|
+
: options.groupBy === 'project'
|
|
678
|
+
? `IFNULL(NULLIF(project, ''), '(no project)')`
|
|
679
|
+
// ISO timestamps are lexicographically date-sortable; the date is the
|
|
680
|
+
// first 10 chars (YYYY-MM-DD).
|
|
681
|
+
: `substr(timestamp, 1, 10)`;
|
|
682
|
+
const sql = `
|
|
683
|
+
SELECT
|
|
684
|
+
${keyExpr} AS key,
|
|
685
|
+
IFNULL(SUM(cost_usd), 0) AS costUsd,
|
|
686
|
+
IFNULL(SUM(duration_ms), 0) AS durationMs,
|
|
687
|
+
COUNT(*) AS sessionCount,
|
|
688
|
+
IFNULL(SUM(token_count), 0) AS tokenCount
|
|
689
|
+
FROM sessions
|
|
690
|
+
${clause}
|
|
691
|
+
GROUP BY key
|
|
692
|
+
ORDER BY costUsd DESC, key ASC
|
|
693
|
+
`;
|
|
694
|
+
return db.prepare(sql).all(...params);
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Return the N most expensive sessions (cost_usd DESC, NULLs excluded),
|
|
698
|
+
* honoring the same filter shape as querySessions. Drops rows whose JSONL
|
|
699
|
+
* vanished, mirroring querySessions' liveness filter.
|
|
700
|
+
*/
|
|
701
|
+
export function topSessionsByCost(n, options = {}) {
|
|
702
|
+
const db = getDB();
|
|
703
|
+
const { clause, params } = buildSessionWhere(options);
|
|
704
|
+
const whereCost = clause ? `${clause} AND cost_usd IS NOT NULL` : 'WHERE cost_usd IS NOT NULL';
|
|
705
|
+
const limit = Math.max(1, Math.floor(n));
|
|
706
|
+
// Over-fetch a small buffer to survive the on-disk liveness filter below.
|
|
707
|
+
const sql = `SELECT * FROM sessions ${whereCost} ORDER BY cost_usd DESC, timestamp DESC LIMIT ${limit + 16}`;
|
|
708
|
+
const rows = db.prepare(sql).all(...params);
|
|
709
|
+
const live = rows.filter(r => !r.file_path || fs.existsSync(r.file_path));
|
|
710
|
+
return live.slice(0, limit).map(r => ({
|
|
711
|
+
meta: rowToMeta(r),
|
|
712
|
+
costUsd: r.cost_usd ?? 0,
|
|
713
|
+
durationMs: r.duration_ms ?? 0,
|
|
714
|
+
}));
|
|
715
|
+
}
|
|
634
716
|
/** Return the set of all file paths currently tracked in the sessions table. */
|
|
635
717
|
export function getAllFilePaths() {
|
|
636
718
|
const db = getDB();
|
|
@@ -25,6 +25,8 @@ export interface DiscoverOptions {
|
|
|
25
25
|
excludeTeamOrigin?: boolean;
|
|
26
26
|
/** Keep only team-spawned sessions (used for hidden-count queries). */
|
|
27
27
|
onlyTeamOrigin?: boolean;
|
|
28
|
+
/** Column to order results by (all descending): 'timestamp' (default), 'cost', or 'duration'. */
|
|
29
|
+
sortBy?: 'timestamp' | 'cost' | 'duration';
|
|
28
30
|
/** Called as each agent makes parsing progress. Totals count only files that need re-parsing (cache misses). */
|
|
29
31
|
onProgress?: (progress: ScanProgress) => void;
|
|
30
32
|
}
|