@phnx-labs/agents-cli 1.20.3 → 1.20.5

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 (193) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +48 -17
  3. package/dist/commands/cli.js +1 -1
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/commands.js +2 -0
  6. package/dist/commands/doctor.js +1 -1
  7. package/dist/commands/exec.js +52 -16
  8. package/dist/commands/hooks.js +6 -6
  9. package/dist/commands/import.js +90 -37
  10. package/dist/commands/inspect.d.ts +26 -0
  11. package/dist/commands/inspect.js +590 -0
  12. package/dist/commands/mcp.js +17 -16
  13. package/dist/commands/models.js +1 -1
  14. package/dist/commands/packages.js +6 -4
  15. package/dist/commands/permissions.js +13 -12
  16. package/dist/commands/plugins.d.ts +13 -0
  17. package/dist/commands/plugins.js +100 -11
  18. package/dist/commands/prune.js +3 -2
  19. package/dist/commands/pull.d.ts +12 -5
  20. package/dist/commands/pull.js +26 -422
  21. package/dist/commands/push.d.ts +14 -0
  22. package/dist/commands/push.js +30 -0
  23. package/dist/commands/repo.d.ts +1 -1
  24. package/dist/commands/repo.js +155 -112
  25. package/dist/commands/resource-view.d.ts +2 -0
  26. package/dist/commands/resource-view.js +12 -3
  27. package/dist/commands/routines.js +32 -7
  28. package/dist/commands/rules.js +1 -1
  29. package/dist/commands/sessions.js +1 -0
  30. package/dist/commands/setup.d.ts +3 -3
  31. package/dist/commands/setup.js +15 -15
  32. package/dist/commands/skills.js +6 -5
  33. package/dist/commands/subagents.js +5 -4
  34. package/dist/commands/sync.d.ts +18 -5
  35. package/dist/commands/sync.js +251 -65
  36. package/dist/commands/teams.js +1 -0
  37. package/dist/commands/tmux.d.ts +25 -0
  38. package/dist/commands/tmux.js +415 -0
  39. package/dist/commands/trash.d.ts +2 -2
  40. package/dist/commands/trash.js +1 -1
  41. package/dist/commands/versions.js +2 -2
  42. package/dist/commands/view.js +14 -4
  43. package/dist/commands/workflows.js +4 -3
  44. package/dist/commands/worktree.d.ts +4 -5
  45. package/dist/commands/worktree.js +4 -4
  46. package/dist/index.js +68 -20
  47. package/dist/lib/agents.d.ts +19 -10
  48. package/dist/lib/agents.js +102 -28
  49. package/dist/lib/auto-pull-worker.d.ts +1 -1
  50. package/dist/lib/auto-pull-worker.js +2 -2
  51. package/dist/lib/auto-pull.d.ts +1 -1
  52. package/dist/lib/auto-pull.js +1 -1
  53. package/dist/lib/beta.d.ts +1 -1
  54. package/dist/lib/beta.js +1 -1
  55. package/dist/lib/capabilities.js +2 -0
  56. package/dist/lib/commands.d.ts +28 -1
  57. package/dist/lib/commands.js +125 -20
  58. package/dist/lib/doctor-diff.js +2 -2
  59. package/dist/lib/exec.d.ts +14 -0
  60. package/dist/lib/exec.js +39 -5
  61. package/dist/lib/fuzzy.d.ts +12 -2
  62. package/dist/lib/fuzzy.js +29 -4
  63. package/dist/lib/git.js +8 -1
  64. package/dist/lib/hooks.d.ts +2 -2
  65. package/dist/lib/hooks.js +97 -10
  66. package/dist/lib/import.d.ts +21 -0
  67. package/dist/lib/import.js +55 -2
  68. package/dist/lib/mcp.js +32 -2
  69. package/dist/lib/migrate.d.ts +51 -0
  70. package/dist/lib/migrate.js +227 -1
  71. package/dist/lib/models.js +62 -15
  72. package/dist/lib/permissions.d.ts +36 -2
  73. package/dist/lib/permissions.js +217 -7
  74. package/dist/lib/plugin-marketplace.d.ts +108 -40
  75. package/dist/lib/plugin-marketplace.js +243 -94
  76. package/dist/lib/plugins.d.ts +21 -4
  77. package/dist/lib/plugins.js +130 -49
  78. package/dist/lib/profiles-presets.js +12 -12
  79. package/dist/lib/project-launch.d.ts +65 -0
  80. package/dist/lib/project-launch.js +367 -0
  81. package/dist/lib/pty-client.js +1 -1
  82. package/dist/lib/pty-server.d.ts +1 -1
  83. package/dist/lib/pty-server.js +28 -4
  84. package/dist/lib/refresh.d.ts +26 -0
  85. package/dist/lib/refresh.js +315 -0
  86. package/dist/lib/resource-patterns.d.ts +1 -1
  87. package/dist/lib/resource-patterns.js +1 -1
  88. package/dist/lib/resources/commands.js +2 -2
  89. package/dist/lib/resources/hooks.d.ts +1 -1
  90. package/dist/lib/resources/hooks.js +1 -1
  91. package/dist/lib/resources/mcp.d.ts +1 -1
  92. package/dist/lib/resources/mcp.js +5 -6
  93. package/dist/lib/resources/permissions.js +5 -2
  94. package/dist/lib/resources/rules.js +3 -2
  95. package/dist/lib/resources/skills.js +3 -2
  96. package/dist/lib/resources/types.d.ts +1 -1
  97. package/dist/lib/resources.js +2 -2
  98. package/dist/lib/rotate.d.ts +1 -1
  99. package/dist/lib/rotate.js +1 -1
  100. package/dist/lib/routines.d.ts +16 -4
  101. package/dist/lib/routines.js +67 -17
  102. package/dist/lib/rules/compile.js +22 -10
  103. package/dist/lib/rules/rules.js +3 -3
  104. package/dist/lib/runner.js +16 -3
  105. package/dist/lib/scheduler.js +15 -1
  106. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  107. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  108. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +9 -1
  109. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  110. package/dist/lib/secrets/linux.d.ts +44 -9
  111. package/dist/lib/secrets/linux.js +302 -48
  112. package/dist/lib/session/db.js +15 -2
  113. package/dist/lib/session/discover.js +118 -3
  114. package/dist/lib/session/parse.js +3 -0
  115. package/dist/lib/session/types.d.ts +1 -1
  116. package/dist/lib/session/types.js +1 -1
  117. package/dist/lib/shims.d.ts +10 -9
  118. package/dist/lib/shims.js +101 -50
  119. package/dist/lib/skills.d.ts +1 -1
  120. package/dist/lib/skills.js +10 -9
  121. package/dist/lib/staleness/detectors/commands.d.ts +3 -0
  122. package/dist/lib/staleness/detectors/commands.js +46 -0
  123. package/dist/lib/staleness/detectors/hooks.d.ts +3 -0
  124. package/dist/lib/staleness/detectors/hooks.js +44 -0
  125. package/dist/lib/staleness/detectors/mcp.d.ts +3 -0
  126. package/dist/lib/staleness/detectors/mcp.js +31 -0
  127. package/dist/lib/staleness/detectors/permissions.d.ts +3 -0
  128. package/dist/lib/staleness/detectors/permissions.js +201 -0
  129. package/dist/lib/staleness/detectors/plugins.d.ts +8 -0
  130. package/dist/lib/staleness/detectors/plugins.js +23 -0
  131. package/dist/lib/staleness/detectors/rules.d.ts +3 -0
  132. package/dist/lib/staleness/detectors/rules.js +34 -0
  133. package/dist/lib/staleness/detectors/skills.d.ts +3 -0
  134. package/dist/lib/staleness/detectors/skills.js +71 -0
  135. package/dist/lib/staleness/detectors/subagents.d.ts +3 -0
  136. package/dist/lib/staleness/detectors/subagents.js +50 -0
  137. package/dist/lib/staleness/detectors/types.d.ts +22 -0
  138. package/dist/lib/staleness/detectors/types.js +1 -0
  139. package/dist/lib/staleness/detectors/workflows.d.ts +3 -0
  140. package/dist/lib/staleness/detectors/workflows.js +28 -0
  141. package/dist/lib/staleness/registry.d.ts +26 -0
  142. package/dist/lib/staleness/registry.js +123 -0
  143. package/dist/lib/staleness/writers/commands.d.ts +3 -0
  144. package/dist/lib/staleness/writers/commands.js +111 -0
  145. package/dist/lib/staleness/writers/hooks.d.ts +3 -0
  146. package/dist/lib/staleness/writers/hooks.js +47 -0
  147. package/dist/lib/staleness/writers/kinds.d.ts +10 -0
  148. package/dist/lib/staleness/writers/kinds.js +15 -0
  149. package/dist/lib/staleness/writers/lazy-map.d.ts +13 -0
  150. package/dist/lib/staleness/writers/lazy-map.js +19 -0
  151. package/dist/lib/staleness/writers/mcp.d.ts +10 -0
  152. package/dist/lib/staleness/writers/mcp.js +19 -0
  153. package/dist/lib/staleness/writers/permissions.d.ts +13 -0
  154. package/dist/lib/staleness/writers/permissions.js +26 -0
  155. package/dist/lib/staleness/writers/plugins.d.ts +7 -0
  156. package/dist/lib/staleness/writers/plugins.js +31 -0
  157. package/dist/lib/staleness/writers/rules.d.ts +7 -0
  158. package/dist/lib/staleness/writers/rules.js +55 -0
  159. package/dist/lib/staleness/writers/skills.d.ts +3 -0
  160. package/dist/lib/staleness/writers/skills.js +81 -0
  161. package/dist/lib/staleness/writers/sources.d.ts +16 -0
  162. package/dist/lib/staleness/writers/sources.js +72 -0
  163. package/dist/lib/staleness/writers/subagents.d.ts +3 -0
  164. package/dist/lib/staleness/writers/subagents.js +53 -0
  165. package/dist/lib/staleness/writers/types.d.ts +36 -0
  166. package/dist/lib/staleness/writers/types.js +1 -0
  167. package/dist/lib/staleness/writers/workflows.d.ts +7 -0
  168. package/dist/lib/staleness/writers/workflows.js +31 -0
  169. package/dist/lib/state.d.ts +34 -11
  170. package/dist/lib/state.js +58 -13
  171. package/dist/lib/subagents.d.ts +0 -2
  172. package/dist/lib/subagents.js +6 -6
  173. package/dist/lib/teams/agents.js +1 -1
  174. package/dist/lib/teams/parsers.d.ts +1 -1
  175. package/dist/lib/tmux/binary.d.ts +67 -0
  176. package/dist/lib/tmux/binary.js +141 -0
  177. package/dist/lib/tmux/index.d.ts +8 -0
  178. package/dist/lib/tmux/index.js +8 -0
  179. package/dist/lib/tmux/paths.d.ts +17 -0
  180. package/dist/lib/tmux/paths.js +30 -0
  181. package/dist/lib/tmux/session.d.ts +122 -0
  182. package/dist/lib/tmux/session.js +305 -0
  183. package/dist/lib/types.d.ts +58 -7
  184. package/dist/lib/types.js +1 -1
  185. package/dist/lib/usage.js +1 -1
  186. package/dist/lib/versions.d.ts +4 -4
  187. package/dist/lib/versions.js +154 -491
  188. package/dist/lib/workflows.d.ts +2 -4
  189. package/dist/lib/workflows.js +3 -4
  190. package/package.json +7 -7
  191. package/scripts/postinstall.js +16 -63
  192. package/dist/commands/status.d.ts +0 -9
  193. package/dist/commands/status.js +0 -25
@@ -1,17 +1,32 @@
1
1
  /**
2
2
  * Linux secret storage via libsecret (GNOME Keyring / Secret Service API).
3
3
  *
4
- * Uses `secret-tool` CLI which is part of libsecret-tools package.
5
- * On Ubuntu: apt install libsecret-tools
4
+ * Primary backend: `secret-tool` CLI (libsecret-tools package).
6
5
  *
7
- * Secrets are stored with:
6
+ * Headless fallback: when the default Secret Service collection is locked
7
+ * (common on server-class Linux — no graphical login means the keyring
8
+ * passphrase never enters the daemon, so `secret-tool store` fails with
9
+ * "Cannot create an item in a locked collection"), we transparently switch
10
+ * to a file-based AES-256-GCM encrypted store under
11
+ * `~/.agents/.cache/secrets/`. The encryption key is scrypt-derived from a
12
+ * passphrase read from `AGENTS_SECRETS_PASSPHRASE` (preferred) or a TTY
13
+ * prompt. The decision is cached per process; one stderr line is emitted
14
+ * the first time the fallback activates.
15
+ *
16
+ * Secrets stored via secret-tool use:
8
17
  * service = "agents-cli"
9
18
  * account = username
10
- * item = the secret identifier
19
+ * item = the secret identifier
20
+ *
21
+ * File-fallback layout: one `<item>.enc` JSON file per item, mode 0600.
11
22
  */
12
- import { spawnSync } from 'child_process';
23
+ import { spawnSync, execSync } from 'child_process';
24
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
25
+ import * as fs from 'fs';
13
26
  import * as os from 'os';
27
+ import * as path from 'path';
14
28
  const SERVICE = 'agents-cli';
29
+ // ---------- secret-tool availability ----------
15
30
  function secretToolAvailable() {
16
31
  const result = spawnSync('which', ['secret-tool'], {
17
32
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -20,108 +35,334 @@ function secretToolAvailable() {
20
35
  }
21
36
  let checkedAvailability = false;
22
37
  let isAvailable = false;
23
- function ensureSecretTool() {
38
+ // ---------- file fallback state ----------
39
+ let useFileFallback = false;
40
+ let warnedFallback = false;
41
+ let fileDirOverride = null;
42
+ let cachedPassphrase = null;
43
+ function fileDir() {
44
+ return fileDirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'secrets');
45
+ }
46
+ function activateFileFallback() {
47
+ if (useFileFallback)
48
+ return;
49
+ useFileFallback = true;
50
+ if (!warnedFallback) {
51
+ warnedFallback = true;
52
+ process.stderr.write(`[agents] secret-service collection locked, using file-based store at ${fileDir()}\n`);
53
+ }
54
+ }
55
+ function isLockedCollectionError(stderr) {
56
+ return /locked collection/i.test(stderr) ||
57
+ /Prompt was dismissed/i.test(stderr);
58
+ }
59
+ /** True if the fallback dir has any committed encrypted items. Means an
60
+ * earlier process (this one or another) already routed writes to the file
61
+ * store, so this process must keep reading/writing from the same store —
62
+ * otherwise `list` / `get` / `has` would silently miss them. */
63
+ function fileFallbackPreviouslyActivated() {
64
+ try {
65
+ return fs.readdirSync(fileDir()).some((e) => e.endsWith('.enc'));
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /**
72
+ * Decide which backend a given op should use. Activates file fallback if
73
+ * `secret-tool` is missing and `AGENTS_SECRETS_PASSPHRASE` is set, OR if a
74
+ * previous run already committed to the file fallback (encrypted items on
75
+ * disk). The latter check is what makes the fallback persistent across the
76
+ * many short-lived `agents secrets ...` Node processes a user invokes.
77
+ */
78
+ function preflight() {
79
+ if (useFileFallback)
80
+ return 'file';
81
+ if (fileFallbackPreviouslyActivated()) {
82
+ activateFileFallback();
83
+ return 'file';
84
+ }
24
85
  if (!checkedAvailability) {
25
86
  isAvailable = secretToolAvailable();
26
87
  checkedAvailability = true;
27
88
  }
28
89
  if (!isAvailable) {
90
+ if (process.env.AGENTS_SECRETS_PASSPHRASE) {
91
+ activateFileFallback();
92
+ return 'file';
93
+ }
29
94
  throw new Error('secret-tool not found. Install libsecret-tools:\n' +
30
95
  ' Ubuntu/Debian: sudo apt install libsecret-tools\n' +
31
96
  ' Fedora: sudo dnf install libsecret\n' +
32
- ' Arch: sudo pacman -S libsecret');
97
+ ' Arch: sudo pacman -S libsecret\n' +
98
+ '\n' +
99
+ 'Alternative: set AGENTS_SECRETS_PASSPHRASE to use the encrypted-file fallback.');
33
100
  }
101
+ return 'secret-tool';
34
102
  }
35
- /**
36
- * secret-tool lookup attributes:
37
- * service=agents-cli account=<user> item=<itemName>
38
- */
103
+ // ---------- passphrase ----------
104
+ function readPassphraseFromTty() {
105
+ const fd = fs.openSync('/dev/tty', 'r+');
106
+ let echoDisabled = false;
107
+ try {
108
+ fs.writeSync(fd, 'Enter AGENTS_SECRETS_PASSPHRASE: ');
109
+ try {
110
+ execSync('stty -echo < /dev/tty', { stdio: 'ignore' });
111
+ echoDisabled = true;
112
+ }
113
+ catch {
114
+ // stty not available — fall through; passphrase will echo. Better
115
+ // than refusing to function.
116
+ }
117
+ let pass = '';
118
+ const buf = Buffer.alloc(1);
119
+ while (true) {
120
+ const n = fs.readSync(fd, buf, 0, 1, null);
121
+ if (n === 0)
122
+ break;
123
+ const ch = buf.toString('utf8', 0, n);
124
+ if (ch === '\n' || ch === '\r')
125
+ break;
126
+ pass += ch;
127
+ }
128
+ return pass;
129
+ }
130
+ finally {
131
+ if (echoDisabled) {
132
+ try {
133
+ execSync('stty echo < /dev/tty', { stdio: 'ignore' });
134
+ }
135
+ catch { /* best effort */ }
136
+ }
137
+ try {
138
+ fs.writeSync(fd, '\n');
139
+ }
140
+ catch { /* best effort */ }
141
+ fs.closeSync(fd);
142
+ }
143
+ }
144
+ function getPassphrase() {
145
+ if (cachedPassphrase !== null)
146
+ return cachedPassphrase;
147
+ const env = process.env.AGENTS_SECRETS_PASSPHRASE;
148
+ if (env && env.length > 0) {
149
+ cachedPassphrase = env;
150
+ return env;
151
+ }
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).');
156
+ }
157
+ const p = readPassphraseFromTty();
158
+ if (!p)
159
+ throw new Error('No passphrase entered.');
160
+ cachedPassphrase = p;
161
+ return p;
162
+ }
163
+ function deriveKey(passphrase, salt) {
164
+ return scryptSync(passphrase, salt, 32);
165
+ }
166
+ /** Encrypt plaintext under a passphrase using AES-256-GCM with a random
167
+ * scrypt salt and a random 96-bit IV. Exported for tests. */
168
+ export function encryptForFallback(plaintext, passphrase) {
169
+ const salt = randomBytes(16);
170
+ const iv = randomBytes(12);
171
+ const key = deriveKey(passphrase, salt);
172
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
173
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
174
+ return {
175
+ salt: salt.toString('hex'),
176
+ iv: iv.toString('hex'),
177
+ authTag: cipher.getAuthTag().toString('hex'),
178
+ ciphertext: ciphertext.toString('hex'),
179
+ };
180
+ }
181
+ /** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
182
+ * ciphertext (auth-tag mismatch). Exported for tests. */
183
+ export function decryptForFallback(enc, passphrase) {
184
+ const salt = Buffer.from(enc.salt, 'hex');
185
+ const iv = Buffer.from(enc.iv, 'hex');
186
+ const authTag = Buffer.from(enc.authTag, 'hex');
187
+ const ciphertext = Buffer.from(enc.ciphertext, 'hex');
188
+ const key = deriveKey(passphrase, salt);
189
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
190
+ decipher.setAuthTag(authTag);
191
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
192
+ return plaintext.toString('utf8');
193
+ }
194
+ // ---------- file backend ----------
195
+ function fileFor(item) {
196
+ return path.join(fileDir(), `${item}.enc`);
197
+ }
198
+ function ensureFileDir() {
199
+ fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
200
+ }
201
+ function fileHas(item) {
202
+ return fs.existsSync(fileFor(item));
203
+ }
204
+ function fileGet(item) {
205
+ const fp = fileFor(item);
206
+ if (!fs.existsSync(fp)) {
207
+ throw new Error(`Secret '${item}' not found in encrypted store.`);
208
+ }
209
+ const raw = fs.readFileSync(fp, 'utf8');
210
+ let parsed;
211
+ try {
212
+ parsed = JSON.parse(raw);
213
+ }
214
+ catch {
215
+ throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
216
+ }
217
+ try {
218
+ return decryptForFallback(parsed, getPassphrase());
219
+ }
220
+ catch {
221
+ throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
222
+ }
223
+ }
224
+ function fileSet(item, value) {
225
+ ensureFileDir();
226
+ const enc = encryptForFallback(value, getPassphrase());
227
+ fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
228
+ }
229
+ function fileDelete(item) {
230
+ const fp = fileFor(item);
231
+ if (!fs.existsSync(fp))
232
+ return true; // idempotent, matches secret-tool clear
233
+ fs.unlinkSync(fp);
234
+ return true;
235
+ }
236
+ function fileList(prefix) {
237
+ const dir = fileDir();
238
+ if (!fs.existsSync(dir))
239
+ return [];
240
+ return fs.readdirSync(dir)
241
+ .filter((f) => f.endsWith('.enc'))
242
+ .map((f) => f.slice(0, -'.enc'.length))
243
+ .filter((name) => name.startsWith(prefix));
244
+ }
245
+ /** File-only KeychainBackend (exported for tests; the public surface uses
246
+ * the secret-tool-with-fallback `linuxBackend` below). */
247
+ export const fileBackend = {
248
+ has: fileHas,
249
+ get: fileGet,
250
+ set: fileSet,
251
+ delete: fileDelete,
252
+ list: fileList,
253
+ };
254
+ // ---------- secret-tool ops with fallback ----------
255
+ /** secret-tool lookup attributes:
256
+ * service=agents-cli account=<user> item=<itemName> */
39
257
  export function hasSecretToolToken(item) {
40
- ensureSecretTool();
258
+ if (preflight() === 'file')
259
+ return fileHas(item);
41
260
  const user = os.userInfo().username;
42
261
  const result = spawnSync('secret-tool', [
43
262
  'lookup',
44
263
  'service', SERVICE,
45
264
  'account', user,
46
265
  'item', item,
47
- ], {
48
- stdio: ['ignore', 'pipe', 'pipe'],
49
- });
50
- return result.status === 0 && result.stdout?.toString().trim().length > 0;
266
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
267
+ if (result.status === 0) {
268
+ return result.stdout?.toString().trim().length > 0;
269
+ }
270
+ const stderr = result.stderr?.toString() ?? '';
271
+ if (isLockedCollectionError(stderr)) {
272
+ activateFileFallback();
273
+ return fileHas(item);
274
+ }
275
+ return false;
51
276
  }
52
277
  export function getSecretToolToken(item) {
53
- ensureSecretTool();
278
+ if (preflight() === 'file')
279
+ return fileGet(item);
54
280
  const user = os.userInfo().username;
55
281
  const result = spawnSync('secret-tool', [
56
282
  'lookup',
57
283
  'service', SERVICE,
58
284
  'account', user,
59
285
  'item', item,
60
- ], {
61
- stdio: ['ignore', 'pipe', 'pipe'],
62
- });
63
- if (result.status !== 0) {
64
- throw new Error(`Secret '${item}' not found in keyring.`);
286
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
287
+ if (result.status === 0) {
288
+ const token = result.stdout?.toString().trim();
289
+ if (!token)
290
+ throw new Error(`Secret '${item}' exists but is empty.`);
291
+ return token;
65
292
  }
66
- const token = result.stdout?.toString().trim();
67
- if (!token) {
68
- throw new Error(`Secret '${item}' exists but is empty.`);
293
+ const stderr = result.stderr?.toString() ?? '';
294
+ if (isLockedCollectionError(stderr)) {
295
+ activateFileFallback();
296
+ return fileGet(item);
69
297
  }
70
- return token;
298
+ throw new Error(`Secret '${item}' not found in keyring.`);
71
299
  }
72
300
  export function setSecretToolToken(item, value) {
73
- ensureSecretTool();
74
301
  if (!value || !value.trim())
75
302
  throw new Error('Secret value is empty.');
303
+ if (preflight() === 'file')
304
+ return fileSet(item, value);
76
305
  const user = os.userInfo().username;
77
306
  const label = `agents-cli: ${item}`;
78
- // secret-tool store reads value from stdin
79
307
  const result = spawnSync('secret-tool', [
80
308
  'store',
81
309
  '--label', label,
82
310
  'service', SERVICE,
83
311
  'account', user,
84
312
  '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.');
313
+ ], { input: value, stdio: ['pipe', 'pipe', 'pipe'] });
314
+ if (result.status === 0)
315
+ return;
316
+ const stderr = result.stderr?.toString().trim() ?? '';
317
+ if (isLockedCollectionError(stderr)) {
318
+ activateFileFallback();
319
+ fileSet(item, value);
320
+ return;
93
321
  }
322
+ throw new Error(`Failed to store secret '${item}': ${stderr || 'unknown error'}\n` +
323
+ 'Make sure GNOME Keyring or another Secret Service provider is running,\n' +
324
+ 'or set AGENTS_SECRETS_PASSPHRASE to use the encrypted-file fallback.');
94
325
  }
95
326
  export function deleteSecretToolToken(item) {
96
- ensureSecretTool();
327
+ if (preflight() === 'file')
328
+ return fileDelete(item);
97
329
  const user = os.userInfo().username;
98
330
  const result = spawnSync('secret-tool', [
99
331
  'clear',
100
332
  'service', SERVICE,
101
333
  'account', user,
102
334
  'item', item,
103
- ], {
104
- stdio: ['ignore', 'pipe', 'pipe'],
105
- });
335
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
336
+ if (result.status === 0)
337
+ return true;
338
+ const stderr = result.stderr?.toString() ?? '';
339
+ if (isLockedCollectionError(stderr)) {
340
+ activateFileFallback();
341
+ return fileDelete(item);
342
+ }
106
343
  // 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;
344
+ // A non-zero exit that isn't a locked-collection error is a real failure;
345
+ // surface that rather than silently swallowing.
346
+ return false;
109
347
  }
110
348
  /**
111
349
  * List secrets by prefix. secret-tool doesn't have a list command,
112
350
  * so we use secret-tool search which outputs in a specific format.
113
351
  */
114
352
  export function listSecretToolItems(prefix) {
115
- ensureSecretTool();
116
- // secret-tool search outputs attributes, one item per block
353
+ if (preflight() === 'file')
354
+ return fileList(prefix);
117
355
  const result = spawnSync('secret-tool', [
118
356
  'search',
119
357
  '--all',
120
358
  'service', SERVICE,
121
- ], {
122
- stdio: ['ignore', 'pipe', 'pipe'],
123
- });
359
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
124
360
  if (result.status !== 0) {
361
+ const stderr = result.stderr?.toString() ?? '';
362
+ if (isLockedCollectionError(stderr)) {
363
+ activateFileFallback();
364
+ return fileList(prefix);
365
+ }
125
366
  return [];
126
367
  }
127
368
  const output = result.stdout?.toString() || '';
@@ -141,7 +382,10 @@ export function listSecretToolItems(prefix) {
141
382
  }
142
383
  return [...new Set(items)]; // dedupe
143
384
  }
144
- /** KeychainBackend implementation for Linux using secret-tool */
385
+ /** KeychainBackend implementation for Linux. Routes through secret-tool
386
+ * with a transparent encrypted-file fallback when the default Secret
387
+ * Service collection is locked (or libsecret-tools is not installed but
388
+ * AGENTS_SECRETS_PASSPHRASE is set). */
145
389
  export const linuxBackend = {
146
390
  has(item) {
147
391
  return hasSecretToolToken(item);
@@ -159,3 +403,13 @@ export const linuxBackend = {
159
403
  return listSecretToolItems(prefix);
160
404
  },
161
405
  };
406
+ /** Test-only: reset module state so independent test cases don't bleed
407
+ * passphrase / fallback decisions across each other. */
408
+ export function _resetForTest(opts = {}) {
409
+ fileDirOverride = opts.fileDir ?? null;
410
+ useFileFallback = opts.forceFileFallback ?? false;
411
+ warnedFallback = false;
412
+ cachedPassphrase = opts.passphrase ?? null;
413
+ checkedAvailability = false;
414
+ isAvailable = false;
415
+ }
@@ -605,10 +605,23 @@ function buildSessionWhere(options) {
605
605
  export function querySessions(options = {}) {
606
606
  const db = getDB();
607
607
  const { clause, params } = buildSessionWhere(options);
608
- const limitClause = options.limit ? `LIMIT ${Math.max(1, Math.floor(options.limit))}` : '';
608
+ // When a LIMIT is in play, we still need to filter stale rows AFTER the query,
609
+ // so over-fetch a small buffer. Without this, a page of 50 rows where the first
610
+ // 5 are stale would return only 45 to the caller even when there are more.
611
+ const limitClause = options.limit
612
+ ? `LIMIT ${Math.max(1, Math.floor(options.limit)) + 16}`
613
+ : '';
609
614
  const sql = `SELECT * FROM sessions ${clause} ORDER BY timestamp DESC ${limitClause}`;
610
615
  const rows = db.prepare(sql).all(...params);
611
- return rows.map(rowToMeta);
616
+ // Belt-and-suspenders: drop rows whose JSONL no longer exists on disk. The
617
+ // authoritative fix is to keep file_path in sync (see updateSessionFilePaths
618
+ // callers), but skipping vanished rows here prevents phantom sessions from
619
+ // surfacing in the Factory UI if any code path forgets to rewrite (#136).
620
+ // Synthetic rows (OpenClaw channels/cron — see scanOpenClawIncremental) carry
621
+ // an empty file_path and are exempt; they're keyed by CLI output, not files.
622
+ const live = rows.filter(r => !r.file_path || fs.existsSync(r.file_path));
623
+ const trimmed = options.limit ? live.slice(0, options.limit) : live;
624
+ return trimmed.map(rowToMeta);
612
625
  }
613
626
  /** Count sessions matching the given filter options. */
614
627
  export function countSessions(options = {}) {
@@ -15,7 +15,7 @@ import { execFile } from 'child_process';
15
15
  import { promisify } from 'util';
16
16
  import { getAgentsDir, getHistoryDir } from '../state.js';
17
17
  const execFileAsync = promisify(execFile);
18
- import { AGENTS, getCliVersion } from '../agents.js';
18
+ import { AGENTS, agentConfigDirName, getCliVersion } from '../agents.js';
19
19
  import { walkForFiles } from '../fs-walk.js';
20
20
  import { getConfigSymlinkVersion } from '../shims.js';
21
21
  import { SESSION_AGENTS } from './types.js';
@@ -61,6 +61,7 @@ export async function discoverSessions(options) {
61
61
  case 'openclaw': return scanOpenClawIncremental();
62
62
  case 'rush': return scanRushIncremental(onProgress);
63
63
  case 'hermes': return scanHermesIncremental(onProgress);
64
+ case 'kimi': return scanKimiIncremental(onProgress);
64
65
  }
65
66
  }));
66
67
  }
@@ -201,14 +202,18 @@ export function getAgentSessionDirs(agent, subdir) {
201
202
  resolved.add(key);
202
203
  dirs.push(dir);
203
204
  }
204
- addDir(path.join(HOME, `.${agent}`, subdir));
205
+ // Config-dir name relative to home — handles nested layouts (antigravity
206
+ // .gemini/antigravity-cli) and ~/.config agents (amp, goose) as well as kimi
207
+ // (.kimi-code). Falls back to `.${agent}` for ids not in the registry.
208
+ const configDirName = agent in AGENTS ? agentConfigDirName(agent) : `.${agent}`;
209
+ addDir(path.join(HOME, configDirName, subdir));
205
210
  for (const root of VERSIONS_ROOTS) {
206
211
  const versionsBase = path.join(root, 'versions', agent);
207
212
  if (!fs.existsSync(versionsBase))
208
213
  continue;
209
214
  try {
210
215
  for (const version of fs.readdirSync(versionsBase)) {
211
- addDir(path.join(versionsBase, version, 'home', `.${agent}`, subdir));
216
+ addDir(path.join(versionsBase, version, 'home', configDirName, subdir));
212
217
  }
213
218
  }
214
219
  catch { /* dir unreadable */ }
@@ -1574,6 +1579,116 @@ function sumKnownNumbers(values) {
1574
1579
  // ---------------------------------------------------------------------------
1575
1580
  // Time range parsing
1576
1581
  // ---------------------------------------------------------------------------
1582
+ // ---------------------------------------------------------------------------
1583
+ // Kimi
1584
+ // ---------------------------------------------------------------------------
1585
+ // Kimi stores sessions under ~/.kimi-code/sessions/<workdir_hash>/session_<uuid>/.
1586
+ // Each session has state.json (metadata) and agents/main/wire.jsonl (conversation).
1587
+ // A session_index.jsonl at ~/.kimi-code/ maps session IDs to directories.
1588
+ /** Incrementally re-scan changed Kimi session state.json files and upsert into the DB. */
1589
+ async function scanKimiIncremental(onProgress) {
1590
+ const filePaths = [];
1591
+ for (const sessionsDir of getAgentSessionDirs('kimi', 'sessions')) {
1592
+ if (!fs.existsSync(sessionsDir))
1593
+ continue;
1594
+ let workDirNames;
1595
+ try {
1596
+ workDirNames = fs.readdirSync(sessionsDir);
1597
+ }
1598
+ catch {
1599
+ continue;
1600
+ }
1601
+ for (const workDirName of workDirNames) {
1602
+ const workDir = path.join(sessionsDir, workDirName);
1603
+ const stat = safeStatSync(workDir);
1604
+ if (!stat?.isDirectory())
1605
+ continue;
1606
+ let sessionNames;
1607
+ try {
1608
+ sessionNames = fs.readdirSync(workDir);
1609
+ }
1610
+ catch {
1611
+ continue;
1612
+ }
1613
+ for (const sessionName of sessionNames) {
1614
+ if (!sessionName.startsWith('session_'))
1615
+ continue;
1616
+ const statePath = path.join(workDir, sessionName, 'state.json');
1617
+ if (!fs.existsSync(statePath))
1618
+ continue;
1619
+ filePaths.push(statePath);
1620
+ }
1621
+ }
1622
+ }
1623
+ const changed = filterChangedFiles(filePaths);
1624
+ if (changed.length === 0)
1625
+ return;
1626
+ onProgress?.({ agent: 'kimi', parsed: 0, total: changed.length });
1627
+ const scanEntries = [];
1628
+ const touched = [];
1629
+ const seen = new Set();
1630
+ let parsed = 0;
1631
+ for (const { filePath, scan } of changed) {
1632
+ try {
1633
+ const result = readKimiMeta(filePath);
1634
+ if (result && !seen.has(result.meta.id)) {
1635
+ seen.add(result.meta.id);
1636
+ scanEntries.push({ meta: result.meta, content: result.content, scan });
1637
+ }
1638
+ else {
1639
+ touched.push({ filePath, scan });
1640
+ }
1641
+ }
1642
+ catch {
1643
+ touched.push({ filePath, scan });
1644
+ }
1645
+ parsed++;
1646
+ onProgress?.({ agent: 'kimi', parsed, total: changed.length });
1647
+ }
1648
+ upsertSessionsBatch(scanEntries);
1649
+ recordScans(touched);
1650
+ }
1651
+ /** Parse a single Kimi session state.json file to extract session metadata. */
1652
+ function readKimiMeta(filePath) {
1653
+ let state;
1654
+ try {
1655
+ state = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1656
+ }
1657
+ catch {
1658
+ return null;
1659
+ }
1660
+ const sessionDir = path.dirname(filePath);
1661
+ const sessionId = path.basename(sessionDir);
1662
+ if (!sessionId.startsWith('session_'))
1663
+ return null;
1664
+ const title = typeof state.title === 'string' ? state.title : undefined;
1665
+ const lastPrompt = typeof state.lastPrompt === 'string' ? state.lastPrompt : undefined;
1666
+ const topic = title || lastPrompt || undefined;
1667
+ const createdAt = typeof state.createdAt === 'string' ? state.createdAt : undefined;
1668
+ const updatedAt = typeof state.updatedAt === 'string' ? state.updatedAt : undefined;
1669
+ const timestamp = updatedAt || createdAt;
1670
+ const shortId = sessionId.replace(/^session_/, '').slice(0, 8);
1671
+ // Try to infer project from session directory path
1672
+ // ~/.kimi-code/sessions/<workdir_hash>/session_<uuid>/
1673
+ const workDirName = path.basename(path.dirname(sessionDir));
1674
+ let project;
1675
+ if (workDirName.startsWith('wd_')) {
1676
+ const parts = workDirName.slice(3).split('_');
1677
+ if (parts.length >= 2) {
1678
+ project = parts.slice(0, -1).join('/');
1679
+ }
1680
+ }
1681
+ const meta = {
1682
+ id: sessionId,
1683
+ shortId,
1684
+ agent: 'kimi',
1685
+ timestamp,
1686
+ project,
1687
+ filePath,
1688
+ topic,
1689
+ };
1690
+ return { meta, content: lastPrompt || '' };
1691
+ }
1577
1692
  /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
1578
1693
  export function parseTimeFilter(input) {
1579
1694
  const relativeMatch = input.match(/^(\d+)([mhdw])$/i);
@@ -105,6 +105,9 @@ export function parseSession(filePath, agent) {
105
105
  case 'hermes':
106
106
  events = parseHermes(filePath);
107
107
  break;
108
+ case 'kimi':
109
+ events = [];
110
+ break; // Kimi event parsing not implemented yet — discover.ts builds metadata only
108
111
  }
109
112
  // Chokepoint: every string field that originated in an untrusted session
110
113
  // file gets stripped of terminal escapes here, so renderers downstream can
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** Agents that store session data on disk and can be discovered by `agents sessions`. */
10
- export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok';
10
+ export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi';
11
11
  /** All agents with session discovery support, in display order. */
12
12
  export declare const SESSION_AGENTS: SessionAgentId[];
13
13
  /** A single normalized event within a session (message, tool call, thinking, etc.). */
@@ -7,4 +7,4 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** All agents with session discovery support, in display order. */
10
- export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok'];
10
+ export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi'];