@phnx-labs/agents-cli 1.20.16 → 1.20.18

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/commands/budget.d.ts +14 -0
  4. package/dist/commands/budget.js +137 -0
  5. package/dist/commands/cost.d.ts +12 -0
  6. package/dist/commands/cost.js +139 -0
  7. package/dist/commands/exec.d.ts +20 -0
  8. package/dist/commands/exec.js +382 -5
  9. package/dist/commands/secrets.d.ts +15 -0
  10. package/dist/commands/secrets.js +250 -4
  11. package/dist/commands/sessions.js +4 -0
  12. package/dist/commands/sync.d.ts +10 -3
  13. package/dist/commands/sync.js +72 -9
  14. package/dist/index.js +4 -0
  15. package/dist/lib/budget/config.d.ts +9 -0
  16. package/dist/lib/budget/config.js +115 -0
  17. package/dist/lib/budget/enforce.d.ts +94 -0
  18. package/dist/lib/budget/enforce.js +151 -0
  19. package/dist/lib/budget/ledger.d.ts +61 -0
  20. package/dist/lib/budget/ledger.js +107 -0
  21. package/dist/lib/budget/preflight.d.ts +110 -0
  22. package/dist/lib/budget/preflight.js +200 -0
  23. package/dist/lib/checkpoint.d.ts +54 -0
  24. package/dist/lib/checkpoint.js +56 -0
  25. package/dist/lib/cloud/rush.js +18 -0
  26. package/dist/lib/exec.d.ts +36 -0
  27. package/dist/lib/exec.js +192 -4
  28. package/dist/lib/git.d.ts +18 -0
  29. package/dist/lib/git.js +67 -4
  30. package/dist/lib/hooks.js +12 -0
  31. package/dist/lib/loop.d.ts +145 -0
  32. package/dist/lib/loop.js +330 -0
  33. package/dist/lib/mcp.d.ts +7 -0
  34. package/dist/lib/mcp.js +24 -0
  35. package/dist/lib/models.d.ts +11 -0
  36. package/dist/lib/models.js +21 -0
  37. package/dist/lib/plugin-marketplace.js +16 -6
  38. package/dist/lib/plugins.js +5 -2
  39. package/dist/lib/pricing/cost.d.ts +46 -0
  40. package/dist/lib/pricing/cost.js +71 -0
  41. package/dist/lib/pricing/index.d.ts +8 -0
  42. package/dist/lib/pricing/index.js +8 -0
  43. package/dist/lib/pricing/prices.json +138 -0
  44. package/dist/lib/pricing/table.d.ts +17 -0
  45. package/dist/lib/pricing/table.js +73 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  47. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  48. package/dist/lib/secrets/agent.d.ts +134 -0
  49. package/dist/lib/secrets/agent.js +501 -0
  50. package/dist/lib/secrets/bundles.d.ts +21 -0
  51. package/dist/lib/secrets/bundles.js +43 -0
  52. package/dist/lib/secrets/drivers/rush.d.ts +14 -0
  53. package/dist/lib/secrets/drivers/rush.js +84 -0
  54. package/dist/lib/secrets/linux.js +88 -10
  55. package/dist/lib/secrets/sync-backend.d.ts +48 -0
  56. package/dist/lib/secrets/sync-backend.js +13 -0
  57. package/dist/lib/secrets/sync.d.ts +15 -23
  58. package/dist/lib/secrets/sync.js +31 -66
  59. package/dist/lib/session/db.d.ts +40 -0
  60. package/dist/lib/session/db.js +84 -2
  61. package/dist/lib/session/discover.d.ts +2 -0
  62. package/dist/lib/session/discover.js +126 -2
  63. package/dist/lib/session/render.d.ts +2 -0
  64. package/dist/lib/session/render.js +1 -1
  65. package/dist/lib/session/types.d.ts +4 -0
  66. package/dist/lib/sync-umbrella.d.ts +76 -0
  67. package/dist/lib/sync-umbrella.js +125 -0
  68. package/dist/lib/teams/agents.d.ts +32 -0
  69. package/dist/lib/teams/agents.js +66 -3
  70. package/dist/lib/teams/api.js +20 -0
  71. package/dist/lib/teams/parsers.js +16 -4
  72. package/dist/lib/types.d.ts +48 -0
  73. package/dist/lib/workflows.d.ts +56 -0
  74. package/dist/lib/workflows.js +72 -5
  75. package/package.json +2 -1
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Rush `SyncBackend` driver — the original (and currently default) transport
3
+ * for `agents secrets push/pull`. Talks to api.prix.dev and authenticates with
4
+ * the session token written by `rush login` (`~/.rush/user.yaml`).
5
+ *
6
+ * This is the ONE place in the secrets module allowed to reference Rush
7
+ * (api.prix.dev / ~/.rush). It is an opt-in driver kept for backwards
8
+ * compatibility with bundles already pushed to Rush; `sync.ts` selects it as
9
+ * the default but the transport seam (`SyncBackend`) lets other backends drop
10
+ * in without touching the crypto or push/pull logic.
11
+ */
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+ import * as yaml from 'yaml';
16
+ const PROXY_BASE = 'https://api.prix.dev';
17
+ const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
18
+ const BUNDLE_ENDPOINT = '/api/v1/secrets/bundles';
19
+ function readRushToken() {
20
+ if (!fs.existsSync(USER_YAML)) {
21
+ throw new Error('Not logged in to Rush. Run `rush login` first.');
22
+ }
23
+ const raw = fs.readFileSync(USER_YAML, 'utf-8');
24
+ const data = yaml.parse(raw);
25
+ const token = data?.session?.access_token;
26
+ if (!token) {
27
+ throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
28
+ }
29
+ return token;
30
+ }
31
+ async function api(method, endpoint, body) {
32
+ const token = readRushToken();
33
+ const url = endpoint.startsWith('http') ? endpoint : `${PROXY_BASE}${endpoint}`;
34
+ return fetch(url, {
35
+ method,
36
+ headers: {
37
+ Authorization: `Bearer ${token}`,
38
+ 'Content-Type': 'application/json',
39
+ },
40
+ body: body === undefined ? undefined : JSON.stringify(body),
41
+ });
42
+ }
43
+ function bundlePath(name) {
44
+ return `${BUNDLE_ENDPOINT}/${encodeURIComponent(name)}`;
45
+ }
46
+ /** The Rush transport. Plaintext never reaches here — only ciphertext envelopes. */
47
+ export const rushSyncBackend = {
48
+ async putEnvelope(name, payload) {
49
+ const res = await api('PUT', bundlePath(name), payload);
50
+ if (!res.ok) {
51
+ const body = await res.text().catch(() => '');
52
+ throw new Error(`Push failed (${res.status} ${res.statusText}): ${body}`);
53
+ }
54
+ },
55
+ async getEnvelope(name) {
56
+ const res = await api('GET', bundlePath(name));
57
+ if (res.status === 404)
58
+ return null;
59
+ if (!res.ok) {
60
+ const body = await res.text().catch(() => '');
61
+ throw new Error(`Pull failed (${res.status} ${res.statusText}): ${body}`);
62
+ }
63
+ return await res.json();
64
+ },
65
+ async deleteEnvelope(name) {
66
+ const res = await api('DELETE', bundlePath(name));
67
+ if (res.status === 404)
68
+ return false;
69
+ if (!res.ok) {
70
+ const body = await res.text().catch(() => '');
71
+ throw new Error(`Delete failed (${res.status} ${res.statusText}): ${body}`);
72
+ }
73
+ return true;
74
+ },
75
+ async listEnvelopes() {
76
+ const res = await api('GET', BUNDLE_ENDPOINT);
77
+ if (!res.ok) {
78
+ const body = await res.text().catch(() => '');
79
+ throw new Error(`List failed (${res.status} ${res.statusText}): ${body}`);
80
+ }
81
+ const data = await res.json();
82
+ return data.bundles ?? [];
83
+ },
84
+ };
@@ -38,6 +38,7 @@ let isAvailable = false;
38
38
  // ---------- file fallback state ----------
39
39
  let useFileFallback = false;
40
40
  let warnedFallback = false;
41
+ let warnedAutoPassphrase = false;
41
42
  let fileDirOverride = null;
42
43
  let cachedPassphrase = null;
43
44
  function fileDir() {
@@ -87,7 +88,13 @@ function preflight() {
87
88
  checkedAvailability = true;
88
89
  }
89
90
  if (!isAvailable) {
90
- if (process.env.AGENTS_SECRETS_PASSPHRASE) {
91
+ // No secret-tool. Route to the encrypted-file fallback whenever a passphrase
92
+ // source exists or can be auto-provisioned: an explicit
93
+ // AGENTS_SECRETS_PASSPHRASE, an already-provisioned machine-local passphrase,
94
+ // or a headless context (no TTY) where getPassphrase() auto-provisions one.
95
+ // Only an INTERACTIVE session with none of these gets the install hint —
96
+ // installing libsecret is the better fix when someone is at the keyboard.
97
+ if (process.env.AGENTS_SECRETS_PASSPHRASE || machinePassphraseExists() || !process.stdin.isTTY) {
91
98
  activateFileFallback();
92
99
  return 'file';
93
100
  }
@@ -141,6 +148,67 @@ function readPassphraseFromTty() {
141
148
  fs.closeSync(fd);
142
149
  }
143
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
+ }
144
212
  function getPassphrase() {
145
213
  if (cachedPassphrase !== null)
146
214
  return cachedPassphrase;
@@ -149,16 +217,25 @@ function getPassphrase() {
149
217
  cachedPassphrase = env;
150
218
  return env;
151
219
  }
152
- if (!process.stdin.isTTY) {
153
- throw new Error('Secret-service collection is locked and no AGENTS_SECRETS_PASSPHRASE is set.\n' +
154
- 'Set AGENTS_SECRETS_PASSPHRASE in your environment to use the encrypted-file fallback,\n' +
155
- 'or unlock the keyring (e.g. configure pam_gnome_keyring for SSH login).');
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;
156
236
  }
157
- const p = readPassphraseFromTty();
158
- if (!p)
159
- throw new Error('No passphrase entered.');
160
- cachedPassphrase = p;
161
- return p;
237
+ cachedPassphrase = provisionMachinePassphrase();
238
+ return cachedPassphrase;
162
239
  }
163
240
  function deriveKey(passphrase, salt) {
164
241
  return scryptSync(passphrase, salt, 32);
@@ -423,6 +500,7 @@ export function _resetForTest(opts = {}) {
423
500
  fileDirOverride = opts.fileDir ?? null;
424
501
  useFileFallback = opts.forceFileFallback ?? false;
425
502
  warnedFallback = false;
503
+ warnedAutoPassphrase = false;
426
504
  cachedPassphrase = opts.passphrase ?? null;
427
505
  checkedAvailability = false;
428
506
  isAvailable = false;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Transport seam for encrypted secrets-bundle sync.
3
+ *
4
+ * A `SyncBackend` moves opaque ciphertext envelopes to and from some remote.
5
+ * It NEVER sees plaintext: encryption (`encryptBlob`) happens in `sync.ts`
6
+ * before `putEnvelope`, and decryption (`decryptBlob`) happens after
7
+ * `getEnvelope`. This mirrors the `KeychainBackend` storage seam
8
+ * (`src/lib/secrets/index.ts`) but abstracts *transport* rather than at-rest
9
+ * storage — so the high-level push/pull logic in `sync.ts` is decoupled from
10
+ * any specific backend (Rush's api.prix.dev, a future Supabase driver, an
11
+ * in-memory test double, …).
12
+ */
13
+ /** Encrypted bundle envelope (AES-256-GCM, key via PBKDF2-SHA256). All byte
14
+ * fields are base64. The server only ever stores/returns this — never plaintext. */
15
+ export interface EncryptedEnvelope {
16
+ v: 1;
17
+ kdf: 'pbkdf2-sha256';
18
+ iter: number;
19
+ salt: string;
20
+ iv: string;
21
+ ct: string;
22
+ tag: string;
23
+ }
24
+ /** A stored bundle object: the ciphertext envelope plus a last-updated stamp. */
25
+ export interface SyncEnvelope {
26
+ envelope: EncryptedEnvelope;
27
+ updated_at: string;
28
+ }
29
+ /** Lightweight listing entry returned by `listEnvelopes`. */
30
+ export interface RemoteBundleSummary {
31
+ name: string;
32
+ updated_at: string;
33
+ }
34
+ /**
35
+ * Pluggable transport for encrypted bundles. Implementations handle only the
36
+ * wire/storage; the crypto and bundle snapshot/restore stay backend-agnostic
37
+ * in `sync.ts`.
38
+ */
39
+ export interface SyncBackend {
40
+ /** Store (create or overwrite) the envelope for `name`. */
41
+ putEnvelope(name: string, payload: SyncEnvelope): Promise<void>;
42
+ /** Fetch the envelope for `name`, or `null` if the remote has none. */
43
+ getEnvelope(name: string): Promise<SyncEnvelope | null>;
44
+ /** Delete `name` on the remote. Returns false if it didn't exist. */
45
+ deleteEnvelope(name: string): Promise<boolean>;
46
+ /** List every bundle the authenticated user has on the remote. */
47
+ listEnvelopes(): Promise<RemoteBundleSummary[]>;
48
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Transport seam for encrypted secrets-bundle sync.
3
+ *
4
+ * A `SyncBackend` moves opaque ciphertext envelopes to and from some remote.
5
+ * It NEVER sees plaintext: encryption (`encryptBlob`) happens in `sync.ts`
6
+ * before `putEnvelope`, and decryption (`decryptBlob`) happens after
7
+ * `getEnvelope`. This mirrors the `KeychainBackend` storage seam
8
+ * (`src/lib/secrets/index.ts`) but abstracts *transport* rather than at-rest
9
+ * storage — so the high-level push/pull logic in `sync.ts` is decoupled from
10
+ * any specific backend (Rush's api.prix.dev, a future Supabase driver, an
11
+ * in-memory test double, …).
12
+ */
13
+ export {};
@@ -1,28 +1,21 @@
1
1
  /**
2
- * Remote sync client for secrets bundles.
2
+ * Remote sync for secrets bundles — backend-agnostic.
3
3
  *
4
4
  * Replaces the previous "leave it to iCloud Keychain" model with explicit
5
- * push/pull against api.prix.dev. Bundle contents (vars + secret values) are
6
- * encrypted client-side with AES-256-GCM under a key derived from a
7
- * user-supplied passphrase via PBKDF2-SHA256. Plaintext never leaves the
8
- * machine — api.prix.dev only ever sees the ciphertext + KDF parameters.
5
+ * push/pull. Bundle contents (vars + secret values) are encrypted client-side
6
+ * with AES-256-GCM under a key derived from a user-supplied passphrase via
7
+ * PBKDF2-SHA256; only the resulting ciphertext envelope is handed to the
8
+ * transport. The transport itself is a pluggable `SyncBackend` (see
9
+ * `sync-backend.ts`) — the Rush driver (`drivers/rush.ts`, api.prix.dev) is the
10
+ * default for backwards compatibility, swappable via `setSyncBackend`. Plaintext
11
+ * never leaves this module; the backend only ever sees ciphertext + KDF params.
9
12
  */
10
13
  import { type SecretsBundle } from './bundles.js';
14
+ import type { SyncBackend, RemoteBundleSummary, EncryptedEnvelope } from './sync-backend.js';
15
+ export type { EncryptedEnvelope, RemoteBundleSummary } from './sync-backend.js';
11
16
  export declare const MIN_PASSPHRASE_LEN = 12;
12
- /** Envelope for an encrypted bundle. All byte fields are base64. */
13
- export interface EncryptedEnvelope {
14
- v: 1;
15
- kdf: 'pbkdf2-sha256';
16
- iter: number;
17
- salt: string;
18
- iv: string;
19
- ct: string;
20
- tag: string;
21
- }
22
- interface RemoteBundleSummary {
23
- name: string;
24
- updated_at: string;
25
- }
17
+ /** Override the sync transport. Returns the previous backend (restore in tests). */
18
+ export declare function setSyncBackend(next: SyncBackend): SyncBackend;
26
19
  /** Encrypt a JSON-serializable payload with a passphrase. */
27
20
  export declare function encryptBlob(plaintext: string, passphrase: string): EncryptedEnvelope;
28
21
  /** Decrypt an envelope. Throws on bad passphrase (auth tag mismatch). */
@@ -37,7 +30,7 @@ export interface BundleSnapshot {
37
30
  export interface PushOptions {
38
31
  passphrase: string;
39
32
  }
40
- /** Push a local bundle to api.prix.dev. Encrypts client-side; server only sees ciphertext. */
33
+ /** Push a local bundle to the remote. Encrypts client-side; the backend only sees ciphertext. */
41
34
  export declare function pushBundle(name: string, opts: PushOptions): Promise<{
42
35
  updated_at: string;
43
36
  }>;
@@ -47,10 +40,9 @@ export interface PullOptions {
47
40
  /** When true, overwrite an existing local bundle. */
48
41
  force?: boolean;
49
42
  }
50
- /** Pull a bundle by name from api.prix.dev and materialize it locally. */
43
+ /** Pull a bundle by name from the remote and materialize it locally. */
51
44
  export declare function pullBundle(name: string, opts: PullOptions): Promise<SecretsBundle>;
52
45
  /** Delete a bundle on the remote. */
53
46
  export declare function deleteRemoteBundle(name: string): Promise<boolean>;
54
- /** List bundles currently stored on api.prix.dev for this user. */
47
+ /** List bundles currently stored on the remote for this user. */
55
48
  export declare function listRemoteBundles(): Promise<RemoteBundleSummary[]>;
56
- export {};
@@ -1,22 +1,19 @@
1
1
  /**
2
- * Remote sync client for secrets bundles.
2
+ * Remote sync for secrets bundles — backend-agnostic.
3
3
  *
4
4
  * Replaces the previous "leave it to iCloud Keychain" model with explicit
5
- * push/pull against api.prix.dev. Bundle contents (vars + secret values) are
6
- * encrypted client-side with AES-256-GCM under a key derived from a
7
- * user-supplied passphrase via PBKDF2-SHA256. Plaintext never leaves the
8
- * machine — api.prix.dev only ever sees the ciphertext + KDF parameters.
5
+ * push/pull. Bundle contents (vars + secret values) are encrypted client-side
6
+ * with AES-256-GCM under a key derived from a user-supplied passphrase via
7
+ * PBKDF2-SHA256; only the resulting ciphertext envelope is handed to the
8
+ * transport. The transport itself is a pluggable `SyncBackend` (see
9
+ * `sync-backend.ts`) — the Rush driver (`drivers/rush.ts`, api.prix.dev) is the
10
+ * default for backwards compatibility, swappable via `setSyncBackend`. Plaintext
11
+ * never leaves this module; the backend only ever sees ciphertext + KDF params.
9
12
  */
10
13
  import * as crypto from 'crypto';
11
- import * as fs from 'fs';
12
- import * as os from 'os';
13
- import * as path from 'path';
14
- import * as yaml from 'yaml';
15
14
  import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from './index.js';
16
15
  import { readBundle, writeBundle, keychainItemsForBundle, validateBundleName, } from './bundles.js';
17
- const PROXY_BASE = 'https://api.prix.dev';
18
- const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
19
- const BUNDLE_ENDPOINT = '/api/v1/secrets/bundles';
16
+ import { rushSyncBackend } from './drivers/rush.js';
20
17
  // PBKDF2 cost. 600k SHA-256 iters matches OWASP 2023+ guidance and keeps a
21
18
  // passphrase prompt under a second on the hardware the CLI targets.
22
19
  const PBKDF2_ITER = 600_000;
@@ -24,29 +21,19 @@ export const MIN_PASSPHRASE_LEN = 12;
24
21
  const KEY_LEN = 32;
25
22
  const SALT_LEN = 16;
26
23
  const IV_LEN = 12;
27
- function readRushToken() {
28
- if (!fs.existsSync(USER_YAML)) {
29
- throw new Error('Not logged in to Rush. Run `rush login` first.');
30
- }
31
- const raw = fs.readFileSync(USER_YAML, 'utf-8');
32
- const data = yaml.parse(raw);
33
- const token = data?.session?.access_token;
34
- if (!token) {
35
- throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
36
- }
37
- return token;
38
- }
39
- async function api(method, endpoint, body) {
40
- const token = readRushToken();
41
- const url = endpoint.startsWith('http') ? endpoint : `${PROXY_BASE}${endpoint}`;
42
- return fetch(url, {
43
- method,
44
- headers: {
45
- Authorization: `Bearer ${token}`,
46
- 'Content-Type': 'application/json',
47
- },
48
- body: body === undefined ? undefined : JSON.stringify(body),
49
- });
24
+ /**
25
+ * Active transport backend. Defaults to the Rush driver for backwards
26
+ * compatibility with bundles already pushed to api.prix.dev; `setSyncBackend`
27
+ * swaps it (a future Supabase driver, or an in-memory double in tests). The
28
+ * crypto + snapshot/restore below stay backend-agnostic — the backend only
29
+ * ever moves ciphertext envelopes, never plaintext.
30
+ */
31
+ let backend = rushSyncBackend;
32
+ /** Override the sync transport. Returns the previous backend (restore in tests). */
33
+ export function setSyncBackend(next) {
34
+ const prev = backend;
35
+ backend = next;
36
+ return prev;
50
37
  }
51
38
  function deriveKey(passphrase, salt) {
52
39
  return crypto.pbkdf2Sync(passphrase, salt, PBKDF2_ITER, KEY_LEN, 'sha256');
@@ -115,32 +102,23 @@ function restoreSnapshot(snap) {
115
102
  }
116
103
  writeBundle(bundle);
117
104
  }
118
- /** Push a local bundle to api.prix.dev. Encrypts client-side; server only sees ciphertext. */
105
+ /** Push a local bundle to the remote. Encrypts client-side; the backend only sees ciphertext. */
119
106
  export async function pushBundle(name, opts) {
120
107
  validateBundleName(name);
121
108
  const snap = snapshotBundle(name);
122
109
  const envelope = encryptBlob(JSON.stringify(snap), opts.passphrase);
123
110
  const updated_at = new Date().toISOString();
124
111
  const payload = { envelope, updated_at };
125
- const res = await api('PUT', `${BUNDLE_ENDPOINT}/${encodeURIComponent(name)}`, payload);
126
- if (!res.ok) {
127
- const body = await res.text().catch(() => '');
128
- throw new Error(`Push failed (${res.status} ${res.statusText}): ${body}`);
129
- }
112
+ await backend.putEnvelope(name, payload);
130
113
  return { updated_at };
131
114
  }
132
- /** Pull a bundle by name from api.prix.dev and materialize it locally. */
115
+ /** Pull a bundle by name from the remote and materialize it locally. */
133
116
  export async function pullBundle(name, opts) {
134
117
  validateBundleName(name);
135
- const res = await api('GET', `${BUNDLE_ENDPOINT}/${encodeURIComponent(name)}`);
136
- if (res.status === 404) {
137
- throw new Error(`Remote bundle '${name}' not found on api.prix.dev.`);
138
- }
139
- if (!res.ok) {
140
- const body = await res.text().catch(() => '');
141
- throw new Error(`Pull failed (${res.status} ${res.statusText}): ${body}`);
118
+ const data = await backend.getEnvelope(name);
119
+ if (!data) {
120
+ throw new Error(`Remote bundle '${name}' not found.`);
142
121
  }
143
- const data = await res.json();
144
122
  const plaintext = decryptBlob(data.envelope, opts.passphrase);
145
123
  const snap = JSON.parse(plaintext);
146
124
  if (!snap || !snap.bundle || snap.bundle.name !== name) {
@@ -159,22 +137,9 @@ export async function pullBundle(name, opts) {
159
137
  /** Delete a bundle on the remote. */
160
138
  export async function deleteRemoteBundle(name) {
161
139
  validateBundleName(name);
162
- const res = await api('DELETE', `${BUNDLE_ENDPOINT}/${encodeURIComponent(name)}`);
163
- if (res.status === 404)
164
- return false;
165
- if (!res.ok) {
166
- const body = await res.text().catch(() => '');
167
- throw new Error(`Delete failed (${res.status} ${res.statusText}): ${body}`);
168
- }
169
- return true;
140
+ return backend.deleteEnvelope(name);
170
141
  }
171
- /** List bundles currently stored on api.prix.dev for this user. */
142
+ /** List bundles currently stored on the remote for this user. */
172
143
  export async function listRemoteBundles() {
173
- const res = await api('GET', BUNDLE_ENDPOINT);
174
- if (!res.ok) {
175
- const body = await res.text().catch(() => '');
176
- throw new Error(`List failed (${res.status} ${res.statusText}): ${body}`);
177
- }
178
- const data = await res.json();
179
- return data.bundles ?? [];
144
+ return backend.listEnvelopes();
180
145
  }
@@ -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. */