@phnx-labs/agents-cli 1.14.7 → 1.16.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 (105) hide show
  1. package/CHANGELOG.md +78 -39
  2. package/README.md +74 -7
  3. package/dist/commands/alias.js +2 -2
  4. package/dist/commands/beta.js +6 -1
  5. package/dist/commands/browser-picker.d.ts +21 -0
  6. package/dist/commands/browser-picker.js +114 -0
  7. package/dist/commands/browser.js +546 -75
  8. package/dist/commands/commands.js +72 -22
  9. package/dist/commands/daemon.js +2 -2
  10. package/dist/commands/exec.js +9 -2
  11. package/dist/commands/fork.js +2 -2
  12. package/dist/commands/hooks.js +71 -26
  13. package/dist/commands/mcp.js +85 -43
  14. package/dist/commands/plugins.js +48 -15
  15. package/dist/commands/prune.d.ts +0 -20
  16. package/dist/commands/prune.js +291 -16
  17. package/dist/commands/pull.js +3 -3
  18. package/dist/commands/repo.js +1 -1
  19. package/dist/commands/routines.js +2 -2
  20. package/dist/commands/secrets.js +37 -1
  21. package/dist/commands/sessions.js +62 -19
  22. package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
  23. package/dist/commands/{init.js → setup.js} +32 -21
  24. package/dist/commands/skills.js +60 -19
  25. package/dist/commands/subagents.js +41 -13
  26. package/dist/commands/teams.js +2 -3
  27. package/dist/commands/usage.js +6 -0
  28. package/dist/commands/utils.d.ts +16 -0
  29. package/dist/commands/utils.js +32 -0
  30. package/dist/commands/versions.js +8 -6
  31. package/dist/commands/view.js +61 -16
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +17 -20
  34. package/dist/lib/agents.js +2 -2
  35. package/dist/lib/auto-pull-worker.js +2 -3
  36. package/dist/lib/auto-pull.js +2 -2
  37. package/dist/lib/browser/cdp.d.ts +7 -1
  38. package/dist/lib/browser/cdp.js +29 -1
  39. package/dist/lib/browser/chrome.js +6 -3
  40. package/dist/lib/browser/devices.d.ts +4 -0
  41. package/dist/lib/browser/devices.js +27 -0
  42. package/dist/lib/browser/drivers/local.js +9 -4
  43. package/dist/lib/browser/drivers/ssh.d.ts +1 -0
  44. package/dist/lib/browser/drivers/ssh.js +32 -4
  45. package/dist/lib/browser/ipc.js +145 -23
  46. package/dist/lib/browser/profiles.d.ts +5 -2
  47. package/dist/lib/browser/profiles.js +77 -37
  48. package/dist/lib/browser/service.d.ts +84 -13
  49. package/dist/lib/browser/service.js +806 -122
  50. package/dist/lib/browser/types.d.ts +81 -3
  51. package/dist/lib/browser/types.js +16 -0
  52. package/dist/lib/cloud/rush.js +2 -2
  53. package/dist/lib/cloud/store.js +2 -2
  54. package/dist/lib/commands.d.ts +1 -0
  55. package/dist/lib/commands.js +6 -2
  56. package/dist/lib/daemon.js +6 -7
  57. package/dist/lib/doctor-diff.js +4 -4
  58. package/dist/lib/events.d.ts +94 -1
  59. package/dist/lib/events.js +264 -6
  60. package/dist/lib/exec.js +16 -10
  61. package/dist/lib/hooks.d.ts +11 -7
  62. package/dist/lib/hooks.js +125 -49
  63. package/dist/lib/migrate.d.ts +1 -1
  64. package/dist/lib/migrate.js +1178 -21
  65. package/dist/lib/models.js +2 -2
  66. package/dist/lib/permissions.d.ts +14 -11
  67. package/dist/lib/permissions.js +46 -42
  68. package/dist/lib/plugins.d.ts +30 -1
  69. package/dist/lib/plugins.js +75 -3
  70. package/dist/lib/pty-server.js +9 -10
  71. package/dist/lib/resources/hooks.d.ts +5 -1
  72. package/dist/lib/resources/hooks.js +21 -4
  73. package/dist/lib/rotate.js +3 -4
  74. package/dist/lib/routines.d.ts +15 -0
  75. package/dist/lib/routines.js +68 -0
  76. package/dist/lib/runner.js +9 -5
  77. package/dist/lib/secrets/index.d.ts +14 -11
  78. package/dist/lib/secrets/index.js +49 -21
  79. package/dist/lib/secrets/linux.d.ts +27 -0
  80. package/dist/lib/secrets/linux.js +161 -0
  81. package/dist/lib/session/active.d.ts +3 -0
  82. package/dist/lib/session/active.js +92 -6
  83. package/dist/lib/session/cloud.js +2 -2
  84. package/dist/lib/session/db.d.ts +4 -0
  85. package/dist/lib/session/db.js +34 -3
  86. package/dist/lib/session/discover.js +30 -15
  87. package/dist/lib/session/team-filter.js +2 -2
  88. package/dist/lib/shims.d.ts +2 -2
  89. package/dist/lib/shims.js +6 -6
  90. package/dist/lib/skills.js +6 -2
  91. package/dist/lib/state.d.ts +86 -14
  92. package/dist/lib/state.js +150 -23
  93. package/dist/lib/subagents.d.ts +28 -0
  94. package/dist/lib/subagents.js +98 -1
  95. package/dist/lib/sync-manifest.d.ts +1 -1
  96. package/dist/lib/sync-manifest.js +3 -3
  97. package/dist/lib/teams/persistence.js +15 -5
  98. package/dist/lib/teams/registry.js +2 -2
  99. package/dist/lib/types.d.ts +32 -3
  100. package/dist/lib/types.js +3 -3
  101. package/dist/lib/usage.d.ts +1 -1
  102. package/dist/lib/usage.js +15 -48
  103. package/dist/lib/versions.js +31 -21
  104. package/package.json +1 -1
  105. package/scripts/postinstall.js +37 -9
@@ -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
+ };
@@ -8,7 +8,10 @@ export interface ActiveSession {
8
8
  pid?: number;
9
9
  sessionId?: string;
10
10
  cwd?: string;
11
+ /** User-given name from /rename command. */
11
12
  label?: string;
13
+ /** First meaningful line of the initial prompt (extracted topic). */
14
+ topic?: string;
12
15
  sessionFile?: string;
13
16
  startedAtMs?: number;
14
17
  status: ActiveStatus;
@@ -2,7 +2,7 @@
2
2
  * Active-session detection across every context an agent can run in:
3
3
  *
4
4
  * - `terminal` — agents launched from VS Code / Cursor / Codium via the
5
- * agents-cli extension. Published to `~/.agents/runtime/live-terminals.json`
5
+ * agents-cli extension. Published to `~/.agents/.cache/terminals/live-terminals.json`
6
6
  * with PID + session UUID per entry.
7
7
  * - `teams` — agents spawned by `agents teams add`, tracked in
8
8
  * `~/.agents/teams/agents/<id>/meta.json` with a PID the manager polls.
@@ -23,10 +23,12 @@ import { execFile } from 'child_process';
23
23
  import { promisify } from 'util';
24
24
  import { listActiveTasks } from '../cloud/store.js';
25
25
  import { AgentManager } from '../teams/agents.js';
26
- import { getUserAgentsDir } from '../state.js';
26
+ import { getTerminalsDir } from '../state.js';
27
+ import { buildClaudeLabelMap } from './discover.js';
28
+ import { extractSessionTopic } from './prompt.js';
27
29
  const execFileAsync = promisify(execFile);
28
30
  const HOME = os.homedir();
29
- const LIVE_TERMINALS_FILE = path.join(getUserAgentsDir(), 'runtime', 'live-terminals.json');
31
+ const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
30
32
  /**
31
33
  * A process is classified `running` if its session file was touched in the
32
34
  * last 2 minutes. Every Claude/Codex tool-call appends an event, so a
@@ -80,9 +82,9 @@ function readLiveTerminals() {
80
82
  }
81
83
  return Array.from(merged.values());
82
84
  }
83
- /** Convert an absolute cwd to the Claude-project folder name (slashes → dashes). */
85
+ /** Convert an absolute cwd to the Claude-project folder name (slashes and dots → dashes). */
84
86
  function claudeProjectDirName(cwd) {
85
- return cwd.replace(/\//g, '-');
87
+ return cwd.replace(/[/.]/g, '-');
86
88
  }
87
89
  /**
88
90
  * Locate the active Claude session file for a process. If we know the session
@@ -126,6 +128,79 @@ function classifyActivity(sessionFile) {
126
128
  return 'running';
127
129
  }
128
130
  }
131
+ /**
132
+ * Extract the first user message's content from a Claude JSONL file.
133
+ * Reads only the first ~50 lines for speed, since the user message is
134
+ * typically near the top (after system/queue events).
135
+ */
136
+ function extractClaudeUserText(parsed) {
137
+ const msg = parsed.message;
138
+ if (!msg?.content)
139
+ return undefined;
140
+ const content = Array.isArray(msg.content) ? msg.content : [msg.content];
141
+ const texts = [];
142
+ for (const block of content) {
143
+ if (typeof block === 'string')
144
+ texts.push(block);
145
+ else if (block?.type === 'text' && typeof block.text === 'string')
146
+ texts.push(block.text);
147
+ }
148
+ return texts.join('\n').trim() || undefined;
149
+ }
150
+ function quickExtractTopic(sessionFile) {
151
+ let fd;
152
+ try {
153
+ fd = fs.openSync(sessionFile, 'r');
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ try {
159
+ const chunkSize = 256 * 1024;
160
+ const maxBytes = 2 * 1024 * 1024;
161
+ let buffer = '';
162
+ let totalRead = 0;
163
+ let linesChecked = 0;
164
+ const maxLines = 30;
165
+ while (totalRead < maxBytes && linesChecked < maxLines) {
166
+ const chunk = Buffer.alloc(chunkSize);
167
+ const bytesRead = fs.readSync(fd, chunk, 0, chunkSize, totalRead);
168
+ if (bytesRead === 0)
169
+ break;
170
+ totalRead += bytesRead;
171
+ buffer += chunk.toString('utf8', 0, bytesRead);
172
+ let lineStart = 0;
173
+ let lineEnd;
174
+ while ((lineEnd = buffer.indexOf('\n', lineStart)) !== -1 && linesChecked < maxLines) {
175
+ const line = buffer.slice(lineStart, lineEnd);
176
+ lineStart = lineEnd + 1;
177
+ linesChecked++;
178
+ if (!line.trim())
179
+ continue;
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(line);
183
+ }
184
+ catch {
185
+ continue;
186
+ }
187
+ if (parsed.type === 'user') {
188
+ const text = extractClaudeUserText(parsed);
189
+ if (text) {
190
+ const topic = extractSessionTopic(text);
191
+ if (topic)
192
+ return topic;
193
+ }
194
+ }
195
+ }
196
+ buffer = buffer.slice(lineStart);
197
+ }
198
+ }
199
+ finally {
200
+ fs.closeSync(fd);
201
+ }
202
+ return undefined;
203
+ }
129
204
  /** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
130
205
  export async function listTeamsActive() {
131
206
  const mgr = new AgentManager();
@@ -135,6 +210,7 @@ export async function listTeamsActive() {
135
210
  const sessionFile = a.agentType === 'claude' && a.cwd
136
211
  ? findClaudeSessionFile(a.cwd, sessionId ?? undefined)
137
212
  : undefined;
213
+ const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
138
214
  return {
139
215
  context: 'teams',
140
216
  kind: a.agentType,
@@ -142,6 +218,7 @@ export async function listTeamsActive() {
142
218
  sessionId,
143
219
  cwd: a.cwd ?? undefined,
144
220
  label: a.name ?? undefined,
221
+ topic,
145
222
  sessionFile,
146
223
  startedAtMs: a.startedAt.getTime(),
147
224
  status: classifyActivity(sessionFile),
@@ -160,10 +237,16 @@ export async function listTerminalsActive() {
160
237
  const procByPid = new Map();
161
238
  for (const r of await readProcessTable())
162
239
  procByPid.set(r.pid, r);
240
+ // Build label map from Claude's sessions/*.json for /rename support
241
+ const labelMap = buildClaudeLabelMap();
163
242
  return entries.map((t) => {
164
243
  const sessionFile = t.kind === 'claude' && t.cwd
165
244
  ? findClaudeSessionFile(t.cwd, t.sessionId)
166
245
  : undefined;
246
+ // Prefer label from live terminal, fall back to Claude's session label
247
+ const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
248
+ // Extract topic from session file (first meaningful user message)
249
+ const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
167
250
  return {
168
251
  context: 'terminal',
169
252
  kind: t.kind,
@@ -171,7 +254,8 @@ export async function listTerminalsActive() {
171
254
  pid: t.pid,
172
255
  sessionId: t.sessionId,
173
256
  cwd: t.cwd ?? undefined,
174
- label: t.label ?? undefined,
257
+ label,
258
+ topic,
175
259
  sessionFile,
176
260
  startedAtMs: t.startedAtMs,
177
261
  status: classifyActivity(sessionFile),
@@ -355,6 +439,7 @@ export async function listUnattributedActive(attributed) {
355
439
  const { pid, kind } = candidates[i];
356
440
  const cwd = cwds[i];
357
441
  const sessionFile = kind === 'claude' && cwd ? findClaudeSessionFile(cwd) : undefined;
442
+ const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
358
443
  const host = detectHost(pid, procByPid);
359
444
  const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
360
445
  out.push({
@@ -363,6 +448,7 @@ export async function listUnattributedActive(attributed) {
363
448
  host,
364
449
  pid,
365
450
  cwd,
451
+ topic,
366
452
  sessionFile,
367
453
  status: classifyActivity(sessionFile),
368
454
  });
@@ -13,10 +13,10 @@ import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as os from 'os';
15
15
  import * as yaml from 'yaml';
16
- import { getAgentsDir } from '../state.js';
16
+ import { getCacheDir } from '../state.js';
17
17
  const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
18
18
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
19
- const CLOUD_CACHE_DIR = path.join(getAgentsDir(), 'cache', 'cloud-runs');
19
+ const CLOUD_CACHE_DIR = path.join(getCacheDir(), 'cloud-runs');
20
20
  function readToken() {
21
21
  if (!fs.existsSync(USER_YAML)) {
22
22
  throw new Error('Not logged in to Rush. Run `rush login` first.');
@@ -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;
@@ -9,9 +9,9 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import Database from '../sqlite.js';
12
- import { getUserAgentsDir } from '../state.js';
13
- const SESSIONS_DIR = path.join(getUserAgentsDir(), 'sessions');
14
- const DB_PATH = path.join(SESSIONS_DIR, 'sessions.db');
12
+ import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
+ const SESSIONS_DIR = getSessionsDir();
14
+ const DB_PATH = getSessionsDbPath();
15
15
  /** Current schema version; bumped when migrations are added. */
16
16
  const SCHEMA_VERSION = 5;
17
17
  /**
@@ -151,6 +151,11 @@ export function getDB() {
151
151
  db.pragma('journal_mode = WAL');
152
152
  db.pragma('synchronous = NORMAL');
153
153
  db.pragma('temp_store = MEMORY');
154
+ // Wait up to 10s instead of failing immediately on SQLITE_BUSY. Multiple
155
+ // agents (CLIs, indexers, hooks) all open this DB concurrently; without a
156
+ // busy timeout, parallel writers throw "database is locked" the moment one
157
+ // holds the write lock. 10s is well above any realistic transaction here.
158
+ db.pragma('busy_timeout = 10000');
154
159
  db.exec(SCHEMA);
155
160
  const current = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
156
161
  const currentVersion = current ? parseInt(current.value, 10) : 0;
@@ -638,3 +643,29 @@ export function getRowCount() {
638
643
  const textRows = db.prepare(`SELECT COUNT(*) AS c FROM session_text`).get().c;
639
644
  return { sessions, textRows };
640
645
  }
646
+ /** Count sessions older than the given timestamp (for dry-run previews). */
647
+ export function countSessionsOlderThan(cutoffMs) {
648
+ const db = getDB();
649
+ const cutoffIso = new Date(cutoffMs).toISOString();
650
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE timestamp < ?`).get(cutoffIso);
651
+ return row.n;
652
+ }
653
+ /** Delete sessions older than the given timestamp. Returns the number of rows deleted. */
654
+ export function deleteSessionsOlderThan(cutoffMs) {
655
+ const db = getDB();
656
+ const cutoffIso = new Date(cutoffMs).toISOString();
657
+ const rows = db.prepare(`SELECT id, file_path FROM sessions WHERE timestamp < ?`).all(cutoffIso);
658
+ if (rows.length === 0)
659
+ return 0;
660
+ const txn = db.transaction(() => {
661
+ for (const { id, file_path } of rows) {
662
+ db.prepare(`DELETE FROM session_text WHERE session_id = ?`).run(id);
663
+ db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
664
+ if (file_path) {
665
+ db.prepare(`DELETE FROM scan_ledger WHERE file_path = ?`).run(canonicalLedgerKey(file_path));
666
+ }
667
+ }
668
+ });
669
+ txn();
670
+ return rows.length;
671
+ }