@phnx-labs/agents-cli 1.14.6 → 1.15.0

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 (39) hide show
  1. package/README.md +148 -1
  2. package/dist/commands/beta.js +6 -1
  3. package/dist/commands/exec.js +9 -2
  4. package/dist/commands/init.js +10 -0
  5. package/dist/commands/mcp.js +4 -4
  6. package/dist/commands/prune.d.ts +0 -20
  7. package/dist/commands/prune.js +268 -15
  8. package/dist/commands/secrets.js +83 -0
  9. package/dist/commands/teams.js +2 -3
  10. package/dist/commands/usage.js +6 -0
  11. package/dist/commands/versions.js +8 -6
  12. package/dist/lib/browser/chrome.js +1 -1
  13. package/dist/lib/browser/drivers/ssh.d.ts +1 -0
  14. package/dist/lib/browser/drivers/ssh.js +23 -2
  15. package/dist/lib/browser/ipc.js +1 -0
  16. package/dist/lib/browser/service.d.ts +3 -0
  17. package/dist/lib/browser/service.js +114 -6
  18. package/dist/lib/daemon.js +4 -4
  19. package/dist/lib/events.d.ts +159 -0
  20. package/dist/lib/events.js +441 -0
  21. package/dist/lib/exec.js +29 -6
  22. package/dist/lib/permissions.d.ts +6 -3
  23. package/dist/lib/permissions.js +38 -34
  24. package/dist/lib/routines.d.ts +15 -0
  25. package/dist/lib/routines.js +68 -0
  26. package/dist/lib/runner.js +15 -0
  27. package/dist/lib/secrets/bundles.js +7 -1
  28. package/dist/lib/secrets/index.d.ts +14 -11
  29. package/dist/lib/secrets/index.js +49 -21
  30. package/dist/lib/secrets/linux.d.ts +27 -0
  31. package/dist/lib/secrets/linux.js +161 -0
  32. package/dist/lib/session/db.d.ts +4 -0
  33. package/dist/lib/session/db.js +26 -0
  34. package/dist/lib/skills.js +4 -0
  35. package/dist/lib/usage.d.ts +1 -1
  36. package/dist/lib/usage.js +13 -46
  37. package/dist/lib/versions.js +16 -0
  38. package/package.json +1 -1
  39. package/scripts/postinstall.js +37 -9
@@ -350,3 +350,71 @@ export function installJobFromSource(sourcePath, name) {
350
350
  return { success: false, error: err.message };
351
351
  }
352
352
  }
353
+ /** List all job names that have run directories. */
354
+ export function listJobsWithRuns() {
355
+ const runsDir = getRunsDir();
356
+ if (!fs.existsSync(runsDir))
357
+ return [];
358
+ return fs.readdirSync(runsDir, { withFileTypes: true })
359
+ .filter((e) => e.isDirectory())
360
+ .map((e) => e.name);
361
+ }
362
+ /** Count total runs across all jobs. */
363
+ export function countAllRuns() {
364
+ let total = 0;
365
+ for (const jobName of listJobsWithRuns()) {
366
+ total += listRuns(jobName).length;
367
+ }
368
+ return total;
369
+ }
370
+ /** Preview runs that would be pruned (keeping only the most recent `keep` per job). */
371
+ export function previewRunsPrune(keep) {
372
+ const toPrune = [];
373
+ for (const jobName of listJobsWithRuns()) {
374
+ const runs = listRuns(jobName);
375
+ if (runs.length > keep) {
376
+ const toRemove = runs.slice(0, runs.length - keep);
377
+ for (const run of toRemove) {
378
+ toPrune.push({ jobName, runId: run.runId, startedAt: run.startedAt });
379
+ }
380
+ }
381
+ }
382
+ return toPrune;
383
+ }
384
+ /** Delete old runs, keeping only the most recent `keep` per job. Returns bytes freed and run count. */
385
+ export function pruneRuns(keep) {
386
+ let deleted = 0;
387
+ let bytesFreed = 0;
388
+ for (const jobName of listJobsWithRuns()) {
389
+ const runs = listRuns(jobName);
390
+ if (runs.length <= keep)
391
+ continue;
392
+ const toRemove = runs.slice(0, runs.length - keep);
393
+ for (const run of toRemove) {
394
+ const runDir = getRunDir(jobName, run.runId);
395
+ bytesFreed += getDirSize(runDir);
396
+ fs.rmSync(runDir, { recursive: true, force: true });
397
+ deleted++;
398
+ }
399
+ }
400
+ return { deleted, bytesFreed };
401
+ }
402
+ function getDirSize(dirPath) {
403
+ if (!fs.existsSync(dirPath))
404
+ return 0;
405
+ let size = 0;
406
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
407
+ for (const entry of entries) {
408
+ const fullPath = path.join(dirPath, entry.name);
409
+ if (entry.isDirectory()) {
410
+ size += getDirSize(fullPath);
411
+ }
412
+ else {
413
+ try {
414
+ size += fs.statSync(fullPath).size;
415
+ }
416
+ catch { /* ignore */ }
417
+ }
418
+ }
419
+ return size;
420
+ }
@@ -14,6 +14,7 @@ import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, } from './rout
14
14
  import { getRunsDir } from './state.js';
15
15
  import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
16
16
  import { resolveModel, buildReasoningFlags } from './models.js';
17
+ import { createTimer, maybeRotate, truncate } from './events.js';
17
18
  /** CLI command templates per agent, with {prompt} as a placeholder. */
18
19
  const AGENT_COMMANDS = {
19
20
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -107,6 +108,15 @@ function generateRunId() {
107
108
  }
108
109
  /** Execute a job synchronously (waits for completion or timeout before resolving). */
109
110
  export async function executeJob(config) {
111
+ maybeRotate();
112
+ const timer = createTimer('agent.run', {
113
+ agent: config.agent,
114
+ version: config.version,
115
+ jobName: config.name,
116
+ mode: config.mode,
117
+ prompt: truncate(config.prompt, 200),
118
+ schedule: config.schedule,
119
+ });
110
120
  const resolvedPrompt = resolveJobPrompt(config);
111
121
  const cmd = buildJobCommand(config, resolvedPrompt);
112
122
  const useSandbox = config.sandbox !== false;
@@ -138,6 +148,8 @@ export async function executeJob(config) {
138
148
  detached: true,
139
149
  env: spawnEnv,
140
150
  });
151
+ // Mark startup time (time from function call to process spawn)
152
+ timer.mark('startup');
141
153
  meta.pid = child.pid || null;
142
154
  writeRunMeta(meta);
143
155
  let settled = false;
@@ -160,6 +172,7 @@ export async function executeJob(config) {
160
172
  meta.status = 'timeout';
161
173
  meta.completedAt = new Date().toISOString();
162
174
  writeRunMeta(meta);
175
+ timer.end({ status: 'timeout', runId });
163
176
  const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
164
177
  resolve({ meta, reportPath });
165
178
  }, timeoutMs);
@@ -176,6 +189,7 @@ export async function executeJob(config) {
176
189
  meta.status = code === 0 ? 'completed' : 'failed';
177
190
  meta.completedAt = new Date().toISOString();
178
191
  writeRunMeta(meta);
192
+ timer.end({ status: meta.status, exitCode: code ?? undefined, runId });
179
193
  const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
180
194
  resolve({ meta, reportPath });
181
195
  });
@@ -191,6 +205,7 @@ export async function executeJob(config) {
191
205
  meta.status = 'failed';
192
206
  meta.completedAt = new Date().toISOString();
193
207
  writeRunMeta(meta);
208
+ timer.end({ status: 'failed', error: err.message, runId });
194
209
  resolve({ meta, reportPath: null });
195
210
  });
196
211
  child.unref();
@@ -16,6 +16,7 @@ import * as os from 'os';
16
16
  import * as path from 'path';
17
17
  import * as yaml from 'yaml';
18
18
  import { deleteKeychainToken, getKeychainToken, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
19
+ import { emit } from '../events.js';
19
20
  /** Allowed values for a secret's `type` metadata field. */
20
21
  export const SECRET_TYPES = [
21
22
  'api-key',
@@ -148,10 +149,15 @@ export function writeBundle(bundle) {
148
149
  };
149
150
  const json = JSON.stringify(payload);
150
151
  setKeychainToken(bundleMetaItem(bundle.name), json, Boolean(bundle.icloud_sync));
152
+ emit('secrets.set', { bundle: bundle.name });
151
153
  }
152
154
  export function deleteBundle(name) {
153
155
  validateBundleName(name);
154
- return deleteKeychainToken(bundleMetaItem(name));
156
+ const deleted = deleteKeychainToken(bundleMetaItem(name));
157
+ if (deleted) {
158
+ emit('secrets.delete', { bundle: name });
159
+ }
160
+ return deleted;
155
161
  }
156
162
  export function listBundles() {
157
163
  let services;
@@ -1,11 +1,14 @@
1
1
  /**
2
- * macOS Keychain integration for secure credential storage.
2
+ * Cross-platform secure credential storage.
3
3
  *
4
- * All reads/writes go through a signed Swift helper (keychain-helper.swift)
5
- * compiled into AgentsKeychain.app. The .app embeds a provisioning profile
6
- * that grants the application-identifier + keychain-access-groups entitlement
7
- * macOS requires for kSecAttrSynchronizable writes (iCloud Keychain).
8
- * For device-local writes the helper is invoked with the `nosync` arg.
4
+ * macOS: Uses Keychain via signed Swift helper (AgentsKeychain.app) or `security` CLI.
5
+ * Linux: Uses libsecret (GNOME Keyring) via `secret-tool` CLI.
6
+ * Windows: Not yet supported.
7
+ *
8
+ * The .app embeds a provisioning profile that grants the application-identifier
9
+ * + keychain-access-groups entitlement macOS requires for kSecAttrSynchronizable
10
+ * writes (iCloud Keychain). For device-local writes the helper is invoked with
11
+ * the `nosync` arg.
9
12
  */
10
13
  /** Supported secret resolution backends. */
11
14
  export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
@@ -49,15 +52,15 @@ export interface KeychainBackend {
49
52
  }
50
53
  /** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
51
54
  export declare function setKeychainBackendForTest(b: KeychainBackend | null): KeychainBackend | null;
52
- /** Check if a keychain item exists (macOS only). */
55
+ /** Check if a keychain/keyring item exists. */
53
56
  export declare function hasKeychainToken(item: string, sync?: boolean): boolean;
54
- /** Retrieve a secret value from the macOS Keychain. Throws if not found. */
57
+ /** Retrieve a secret value from the keychain/keyring. Throws if not found. */
55
58
  export declare function getKeychainToken(item: string, sync?: boolean): string;
56
- /** Store or update a secret value in the macOS Keychain. iCloud-synced when sync=true. */
59
+ /** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
57
60
  export declare function setKeychainToken(item: string, value: string, sync?: boolean): void;
58
- /** Delete a keychain item. Returns true if it existed. */
61
+ /** Delete a keychain/keyring item. Returns true if it existed. */
59
62
  export declare function deleteKeychainToken(item: string, sync?: boolean): boolean;
60
- /** Enumerate keychain item service names whose name starts with the given prefix. */
63
+ /** Enumerate keychain/keyring item names starting with the given prefix. */
61
64
  export declare function listKeychainItems(prefix: string): string[];
62
65
  /** Options controlling how secret refs are resolved. */
63
66
  export interface ResolveOptions {
@@ -1,17 +1,21 @@
1
1
  /**
2
- * macOS Keychain integration for secure credential storage.
2
+ * Cross-platform secure credential storage.
3
3
  *
4
- * All reads/writes go through a signed Swift helper (keychain-helper.swift)
5
- * compiled into AgentsKeychain.app. The .app embeds a provisioning profile
6
- * that grants the application-identifier + keychain-access-groups entitlement
7
- * macOS requires for kSecAttrSynchronizable writes (iCloud Keychain).
8
- * For device-local writes the helper is invoked with the `nosync` arg.
4
+ * macOS: Uses Keychain via signed Swift helper (AgentsKeychain.app) or `security` CLI.
5
+ * Linux: Uses libsecret (GNOME Keyring) via `secret-tool` CLI.
6
+ * Windows: Not yet supported.
7
+ *
8
+ * The .app embeds a provisioning profile that grants the application-identifier
9
+ * + keychain-access-groups entitlement macOS requires for kSecAttrSynchronizable
10
+ * writes (iCloud Keychain). For device-local writes the helper is invoked with
11
+ * the `nosync` arg.
9
12
  */
10
13
  import { fileURLToPath } from 'url';
11
14
  import { execFileSync, spawnSync } from 'child_process';
12
15
  import * as fs from 'fs';
13
16
  import * as os from 'os';
14
17
  import * as path from 'path';
18
+ import { linuxBackend } from './linux.js';
15
19
  const SERVICE_PREFIX = 'agents-cli';
16
20
  const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
17
21
  /** Parse a bundle value into either a literal string or a typed secret ref. */
@@ -31,11 +35,18 @@ export function parseBundleValue(raw) {
31
35
  export function serializeRef(ref) {
32
36
  return `${ref.provider}:${ref.value}`;
33
37
  }
34
- function assertMacOS() {
35
- if (process.platform !== 'darwin') {
36
- throw new Error('Keychain-based secrets require macOS. On Linux, use environment variables or .env files instead. Native Linux credential store support is planned.');
38
+ function assertSupportedPlatform() {
39
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
40
+ throw new Error('Secure credential storage requires macOS or Linux. ' +
41
+ 'On Windows, use environment variables or .env files instead.');
37
42
  }
38
43
  }
44
+ function isLinux() {
45
+ return process.platform === 'linux';
46
+ }
47
+ function isMacOS() {
48
+ return process.platform === 'darwin';
49
+ }
39
50
  /** Build the keychain item name for a profile provider token. */
40
51
  export function profileKeychainItem(provider) {
41
52
  return `${SERVICE_PREFIX}.${provider}.token`;
@@ -73,12 +84,14 @@ export function setKeychainBackendForTest(b) {
73
84
  // holds the keychain-access-groups entitlement macOS requires for
74
85
  // kSecAttrSynchronizable. Enumeration also goes through the .app because the
75
86
  // security CLI doesn't expose listing by service prefix.
76
- /** Check if a keychain item exists (macOS only). */
87
+ /** Check if a keychain/keyring item exists. */
77
88
  export function hasKeychainToken(item, sync = false) {
78
89
  if (backend)
79
90
  return backend.has(item, sync);
80
- assertMacOS();
81
- // Try security first (no prompts for local items), fall back to binary for synced items.
91
+ assertSupportedPlatform();
92
+ if (isLinux())
93
+ return linuxBackend.has(item, sync);
94
+ // macOS: Try security first (no prompts for local items), fall back to binary for synced items.
82
95
  if (spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
83
96
  stdio: ['ignore', 'pipe', 'pipe'],
84
97
  }).status === 0)
@@ -89,12 +102,14 @@ export function hasKeychainToken(item, sync = false) {
89
102
  stdio: ['ignore', 'pipe', 'pipe'],
90
103
  }).status === 0;
91
104
  }
92
- /** Retrieve a secret value from the macOS Keychain. Throws if not found. */
105
+ /** Retrieve a secret value from the keychain/keyring. Throws if not found. */
93
106
  export function getKeychainToken(item, sync = false) {
94
107
  if (backend)
95
108
  return backend.get(item, sync);
96
- assertMacOS();
97
- // Try security first (no prompts for local items)
109
+ assertSupportedPlatform();
110
+ if (isLinux())
111
+ return linuxBackend.get(item, sync);
112
+ // macOS: Try security first (no prompts for local items)
98
113
  const secResult = spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
99
114
  stdio: ['ignore', 'pipe', 'pipe'],
100
115
  });
@@ -119,17 +134,24 @@ export function getKeychainToken(item, sync = false) {
119
134
  throw new Error(`Keychain item '${item}' exists but is empty.`);
120
135
  return token;
121
136
  }
122
- /** Store or update a secret value in the macOS Keychain. iCloud-synced when sync=true. */
137
+ /** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
123
138
  export function setKeychainToken(item, value, sync = false) {
124
139
  if (backend) {
125
140
  backend.set(item, value, sync);
126
141
  return;
127
142
  }
128
- assertMacOS();
143
+ assertSupportedPlatform();
129
144
  if (!value || !value.trim())
130
145
  throw new Error('Secret value is empty.');
131
146
  if (/[\r\n]/.test(value))
132
147
  throw new Error('Secret value contains newlines, which are not supported.');
148
+ if (/[\x00=\r\n]/.test(item))
149
+ throw new Error('Secret item name contains invalid characters.');
150
+ if (isLinux()) {
151
+ linuxBackend.set(item, value, sync);
152
+ return;
153
+ }
154
+ // macOS path
133
155
  if (sync) {
134
156
  const bin = ensureKeychainHelper();
135
157
  const result = spawnSync(bin, ['set', item, os.userInfo().username], {
@@ -153,11 +175,14 @@ export function setKeychainToken(item, value, sync = false) {
153
175
  throw new Error(`Failed to write keychain item '${item}' (exit ${result.status}).`);
154
176
  }
155
177
  }
156
- /** Delete a keychain item. Returns true if it existed. */
178
+ /** Delete a keychain/keyring item. Returns true if it existed. */
157
179
  export function deleteKeychainToken(item, sync = false) {
158
180
  if (backend)
159
181
  return backend.delete(item, sync);
160
- assertMacOS();
182
+ assertSupportedPlatform();
183
+ if (isLinux())
184
+ return linuxBackend.delete(item, sync);
185
+ // macOS path
161
186
  if (sync) {
162
187
  const bin = ensureKeychainHelper();
163
188
  return spawnSync(bin, ['delete', item, os.userInfo().username], {
@@ -171,11 +196,14 @@ export function deleteKeychainToken(item, sync = false) {
171
196
  function quoteForSecurityCli(s) {
172
197
  return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
173
198
  }
174
- /** Enumerate keychain item service names whose name starts with the given prefix. */
199
+ /** Enumerate keychain/keyring item names starting with the given prefix. */
175
200
  export function listKeychainItems(prefix) {
176
201
  if (backend)
177
202
  return backend.list(prefix);
178
- assertMacOS();
203
+ assertSupportedPlatform();
204
+ if (isLinux())
205
+ return linuxBackend.list(prefix);
206
+ // macOS path
179
207
  const bin = ensureKeychainHelper();
180
208
  const result = spawnSync(bin, ['list', prefix], {
181
209
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Linux secret storage via libsecret (GNOME Keyring / Secret Service API).
3
+ *
4
+ * Uses `secret-tool` CLI which is part of libsecret-tools package.
5
+ * On Ubuntu: apt install libsecret-tools
6
+ *
7
+ * Secrets are stored with:
8
+ * service = "agents-cli"
9
+ * account = username
10
+ * item = the secret identifier
11
+ */
12
+ import type { KeychainBackend } from './index.js';
13
+ /**
14
+ * secret-tool lookup attributes:
15
+ * service=agents-cli account=<user> item=<itemName>
16
+ */
17
+ export declare function hasSecretToolToken(item: string): boolean;
18
+ export declare function getSecretToolToken(item: string): string;
19
+ export declare function setSecretToolToken(item: string, value: string): void;
20
+ export declare function deleteSecretToolToken(item: string): boolean;
21
+ /**
22
+ * List secrets by prefix. secret-tool doesn't have a list command,
23
+ * so we use secret-tool search which outputs in a specific format.
24
+ */
25
+ export declare function listSecretToolItems(prefix: string): string[];
26
+ /** KeychainBackend implementation for Linux using secret-tool */
27
+ export declare const linuxBackend: KeychainBackend;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Linux secret storage via libsecret (GNOME Keyring / Secret Service API).
3
+ *
4
+ * Uses `secret-tool` CLI which is part of libsecret-tools package.
5
+ * On Ubuntu: apt install libsecret-tools
6
+ *
7
+ * Secrets are stored with:
8
+ * service = "agents-cli"
9
+ * account = username
10
+ * item = the secret identifier
11
+ */
12
+ import { spawnSync } from 'child_process';
13
+ import * as os from 'os';
14
+ const SERVICE = 'agents-cli';
15
+ function secretToolAvailable() {
16
+ const result = spawnSync('which', ['secret-tool'], {
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ });
19
+ return result.status === 0;
20
+ }
21
+ let checkedAvailability = false;
22
+ let isAvailable = false;
23
+ function ensureSecretTool() {
24
+ if (!checkedAvailability) {
25
+ isAvailable = secretToolAvailable();
26
+ checkedAvailability = true;
27
+ }
28
+ if (!isAvailable) {
29
+ throw new Error('secret-tool not found. Install libsecret-tools:\n' +
30
+ ' Ubuntu/Debian: sudo apt install libsecret-tools\n' +
31
+ ' Fedora: sudo dnf install libsecret\n' +
32
+ ' Arch: sudo pacman -S libsecret');
33
+ }
34
+ }
35
+ /**
36
+ * secret-tool lookup attributes:
37
+ * service=agents-cli account=<user> item=<itemName>
38
+ */
39
+ export function hasSecretToolToken(item) {
40
+ ensureSecretTool();
41
+ const user = os.userInfo().username;
42
+ const result = spawnSync('secret-tool', [
43
+ 'lookup',
44
+ 'service', SERVICE,
45
+ 'account', user,
46
+ 'item', item,
47
+ ], {
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ });
50
+ return result.status === 0 && result.stdout?.toString().trim().length > 0;
51
+ }
52
+ export function getSecretToolToken(item) {
53
+ ensureSecretTool();
54
+ const user = os.userInfo().username;
55
+ const result = spawnSync('secret-tool', [
56
+ 'lookup',
57
+ 'service', SERVICE,
58
+ 'account', user,
59
+ 'item', item,
60
+ ], {
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ });
63
+ if (result.status !== 0) {
64
+ throw new Error(`Secret '${item}' not found in keyring.`);
65
+ }
66
+ const token = result.stdout?.toString().trim();
67
+ if (!token) {
68
+ throw new Error(`Secret '${item}' exists but is empty.`);
69
+ }
70
+ return token;
71
+ }
72
+ export function setSecretToolToken(item, value) {
73
+ ensureSecretTool();
74
+ if (!value || !value.trim())
75
+ throw new Error('Secret value is empty.');
76
+ const user = os.userInfo().username;
77
+ const label = `agents-cli: ${item}`;
78
+ // secret-tool store reads value from stdin
79
+ const result = spawnSync('secret-tool', [
80
+ 'store',
81
+ '--label', label,
82
+ 'service', SERVICE,
83
+ 'account', user,
84
+ 'item', item,
85
+ ], {
86
+ input: value,
87
+ stdio: ['pipe', 'pipe', 'pipe'],
88
+ });
89
+ if (result.status !== 0) {
90
+ const stderr = result.stderr?.toString().trim();
91
+ throw new Error(`Failed to store secret '${item}': ${stderr || 'unknown error'}\n` +
92
+ 'Make sure GNOME Keyring or another Secret Service provider is running.');
93
+ }
94
+ }
95
+ export function deleteSecretToolToken(item) {
96
+ ensureSecretTool();
97
+ const user = os.userInfo().username;
98
+ const result = spawnSync('secret-tool', [
99
+ 'clear',
100
+ 'service', SERVICE,
101
+ 'account', user,
102
+ 'item', item,
103
+ ], {
104
+ stdio: ['ignore', 'pipe', 'pipe'],
105
+ });
106
+ // secret-tool clear returns 0 whether the item existed or not.
107
+ // This matches the macOS behavior where delete is idempotent.
108
+ return result.status === 0;
109
+ }
110
+ /**
111
+ * List secrets by prefix. secret-tool doesn't have a list command,
112
+ * so we use secret-tool search which outputs in a specific format.
113
+ */
114
+ export function listSecretToolItems(prefix) {
115
+ ensureSecretTool();
116
+ // secret-tool search outputs attributes, one item per block
117
+ const result = spawnSync('secret-tool', [
118
+ 'search',
119
+ '--all',
120
+ 'service', SERVICE,
121
+ ], {
122
+ stdio: ['ignore', 'pipe', 'pipe'],
123
+ });
124
+ if (result.status !== 0) {
125
+ return [];
126
+ }
127
+ const output = result.stdout?.toString() || '';
128
+ const items = [];
129
+ // Parse output format:
130
+ // [/org/freedesktop/secrets/collection/login/1]
131
+ // label = agents-cli: myitem
132
+ // ...
133
+ // attribute.item = myitem
134
+ const itemRegex = /attribute\.item\s*=\s*(.+)/g;
135
+ let match;
136
+ while ((match = itemRegex.exec(output)) !== null) {
137
+ const itemName = match[1].trim();
138
+ if (itemName.startsWith(prefix)) {
139
+ items.push(itemName);
140
+ }
141
+ }
142
+ return [...new Set(items)]; // dedupe
143
+ }
144
+ /** KeychainBackend implementation for Linux using secret-tool */
145
+ export const linuxBackend = {
146
+ has(item, _sync) {
147
+ return hasSecretToolToken(item);
148
+ },
149
+ get(item, _sync) {
150
+ return getSecretToolToken(item);
151
+ },
152
+ set(item, value, _sync) {
153
+ setSecretToolToken(item, value);
154
+ },
155
+ delete(item, _sync) {
156
+ return deleteSecretToolToken(item);
157
+ },
158
+ list(prefix) {
159
+ return listSecretToolItems(prefix);
160
+ },
161
+ };
@@ -137,3 +137,7 @@ export declare function getRowCount(): {
137
137
  sessions: number;
138
138
  textRows: number;
139
139
  };
140
+ /** Count sessions older than the given timestamp (for dry-run previews). */
141
+ export declare function countSessionsOlderThan(cutoffMs: number): number;
142
+ /** Delete sessions older than the given timestamp. Returns the number of rows deleted. */
143
+ export declare function deleteSessionsOlderThan(cutoffMs: number): number;
@@ -638,3 +638,29 @@ export function getRowCount() {
638
638
  const textRows = db.prepare(`SELECT COUNT(*) AS c FROM session_text`).get().c;
639
639
  return { sessions, textRows };
640
640
  }
641
+ /** Count sessions older than the given timestamp (for dry-run previews). */
642
+ export function countSessionsOlderThan(cutoffMs) {
643
+ const db = getDB();
644
+ const cutoffIso = new Date(cutoffMs).toISOString();
645
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE timestamp < ?`).get(cutoffIso);
646
+ return row.n;
647
+ }
648
+ /** Delete sessions older than the given timestamp. Returns the number of rows deleted. */
649
+ export function deleteSessionsOlderThan(cutoffMs) {
650
+ const db = getDB();
651
+ const cutoffIso = new Date(cutoffMs).toISOString();
652
+ const rows = db.prepare(`SELECT id, file_path FROM sessions WHERE timestamp < ?`).all(cutoffIso);
653
+ if (rows.length === 0)
654
+ return 0;
655
+ const txn = db.transaction(() => {
656
+ for (const { id, file_path } of rows) {
657
+ db.prepare(`DELETE FROM session_text WHERE session_id = ?`).run(id);
658
+ db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
659
+ if (file_path) {
660
+ db.prepare(`DELETE FROM scan_ledger WHERE file_path = ?`).run(canonicalLedgerKey(file_path));
661
+ }
662
+ }
663
+ });
664
+ txn();
665
+ return rows.length;
666
+ }
@@ -13,6 +13,7 @@ import * as yaml from 'yaml';
13
13
  import { SKILLS_CAPABLE_AGENTS, ensureSkillsDir } from './agents.js';
14
14
  import { getUserSkillsDir, getSkillsDir as getSystemSkillsDir, getProjectAgentsDir, getEnabledExtraRepos } from './state.js';
15
15
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
16
+ import { emit } from './events.js';
16
17
  const HOME = os.homedir();
17
18
  /** User-scoped skills dir (~/.agents/skills/). Used for installs. */
18
19
  export function getSkillsDir() {
@@ -255,6 +256,7 @@ export function installSkill(sourcePath, skillName, agents, method = 'symlink')
255
256
  };
256
257
  }
257
258
  }
259
+ emit('skill.install', { skill: skillName, agents });
258
260
  return { success: true };
259
261
  }
260
262
  /**
@@ -555,6 +557,7 @@ export function installSkillToVersion(agent, version, skillName, method = 'copy'
555
557
  // Best-effort; failure here shouldn't unwind the install
556
558
  }
557
559
  }
560
+ emit('skill.install', { skill: skillName, agent, version });
558
561
  return { success: true };
559
562
  }
560
563
  /**
@@ -616,6 +619,7 @@ export function uninstallSkill(skillName) {
616
619
  catch {
617
620
  // Ignore removal errors
618
621
  }
622
+ emit('skill.remove', { skill: skillName });
619
623
  return { success: true };
620
624
  }
621
625
  export function listInstalledSkills() {
@@ -79,7 +79,7 @@ export declare function getUsageInfoForIdentity(input: UsageIdentityInput): Prom
79
79
  export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number): string;
80
80
  /** Format a multi-line usage section for detailed agent views. */
81
81
  export declare function formatUsageSection(usage: UsageInfo): string[];
82
- /** Load Claude OAuth credentials from the macOS Keychain. */
82
+ /** Load Claude OAuth credentials from the system keychain/keyring. */
83
83
  export declare function loadClaudeOauth(home?: string): Promise<ClaudeOauthCredentials | null>;
84
84
  /**
85
85
  * Derive the Keychain service name for a Claude home directory.