@phnx-labs/agents-cli 1.15.0 → 1.17.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 (111) hide show
  1. package/CHANGELOG.md +143 -39
  2. package/README.md +6 -6
  3. package/dist/commands/alias.js +2 -2
  4. package/dist/commands/browser-picker.d.ts +21 -0
  5. package/dist/commands/browser-picker.js +114 -0
  6. package/dist/commands/browser.js +793 -83
  7. package/dist/commands/cloud.js +8 -0
  8. package/dist/commands/commands.js +72 -22
  9. package/dist/commands/daemon.js +2 -2
  10. package/dist/commands/exec.js +70 -1
  11. package/dist/commands/hooks.js +71 -26
  12. package/dist/commands/mcp.js +81 -39
  13. package/dist/commands/plugins.js +224 -17
  14. package/dist/commands/prune.js +29 -1
  15. package/dist/commands/pull.js +3 -3
  16. package/dist/commands/repo.js +1 -1
  17. package/dist/commands/routines.js +2 -2
  18. package/dist/commands/secrets.js +154 -20
  19. package/dist/commands/sessions.js +62 -19
  20. package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
  21. package/dist/commands/{init.js → setup.js} +22 -21
  22. package/dist/commands/skills.js +60 -19
  23. package/dist/commands/subagents.js +41 -13
  24. package/dist/commands/utils.d.ts +16 -0
  25. package/dist/commands/utils.js +32 -0
  26. package/dist/commands/view.js +78 -20
  27. package/dist/commands/workflows.d.ts +10 -0
  28. package/dist/commands/workflows.js +457 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +48 -36
  31. package/dist/lib/agents.js +2 -2
  32. package/dist/lib/auto-pull-worker.js +2 -3
  33. package/dist/lib/auto-pull.js +2 -2
  34. package/dist/lib/browser/cdp.d.ts +7 -1
  35. package/dist/lib/browser/cdp.js +32 -1
  36. package/dist/lib/browser/chrome.d.ts +10 -0
  37. package/dist/lib/browser/chrome.js +41 -3
  38. package/dist/lib/browser/devices.d.ts +4 -0
  39. package/dist/lib/browser/devices.js +27 -0
  40. package/dist/lib/browser/drivers/local.js +22 -6
  41. package/dist/lib/browser/drivers/ssh.js +9 -2
  42. package/dist/lib/browser/input.d.ts +1 -0
  43. package/dist/lib/browser/input.js +3 -0
  44. package/dist/lib/browser/ipc.js +158 -23
  45. package/dist/lib/browser/profiles.d.ts +10 -2
  46. package/dist/lib/browser/profiles.js +122 -37
  47. package/dist/lib/browser/service.d.ts +91 -13
  48. package/dist/lib/browser/service.js +767 -132
  49. package/dist/lib/browser/types.d.ts +91 -3
  50. package/dist/lib/browser/types.js +16 -0
  51. package/dist/lib/cloud/rush.d.ts +28 -1
  52. package/dist/lib/cloud/rush.js +69 -14
  53. package/dist/lib/cloud/store.js +2 -2
  54. package/dist/lib/commands.d.ts +1 -15
  55. package/dist/lib/commands.js +11 -7
  56. package/dist/lib/daemon.js +2 -3
  57. package/dist/lib/doctor-diff.js +4 -4
  58. package/dist/lib/events.js +2 -2
  59. package/dist/lib/hooks.d.ts +11 -7
  60. package/dist/lib/hooks.js +138 -49
  61. package/dist/lib/migrate.d.ts +1 -1
  62. package/dist/lib/migrate.js +1237 -22
  63. package/dist/lib/models.js +2 -2
  64. package/dist/lib/permissions.d.ts +8 -66
  65. package/dist/lib/permissions.js +18 -18
  66. package/dist/lib/plugins.d.ts +94 -24
  67. package/dist/lib/plugins.js +702 -123
  68. package/dist/lib/pty-server.js +9 -10
  69. package/dist/lib/resource-patterns.d.ts +41 -0
  70. package/dist/lib/resource-patterns.js +82 -0
  71. package/dist/lib/resources/hooks.d.ts +5 -1
  72. package/dist/lib/resources/hooks.js +21 -4
  73. package/dist/lib/resources/index.d.ts +17 -0
  74. package/dist/lib/resources/index.js +7 -0
  75. package/dist/lib/resources/types.d.ts +1 -1
  76. package/dist/lib/resources/workflows.d.ts +24 -0
  77. package/dist/lib/resources/workflows.js +110 -0
  78. package/dist/lib/resources.d.ts +6 -1
  79. package/dist/lib/resources.js +12 -2
  80. package/dist/lib/rotate.js +3 -4
  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 +18 -0
  85. package/dist/lib/session/db.js +109 -5
  86. package/dist/lib/session/discover.d.ts +6 -0
  87. package/dist/lib/session/discover.js +55 -29
  88. package/dist/lib/session/team-filter.js +2 -2
  89. package/dist/lib/shims.d.ts +4 -52
  90. package/dist/lib/shims.js +23 -15
  91. package/dist/lib/skills.js +6 -2
  92. package/dist/lib/sqlite.js +10 -4
  93. package/dist/lib/state.d.ts +101 -16
  94. package/dist/lib/state.js +179 -31
  95. package/dist/lib/subagents.d.ts +28 -0
  96. package/dist/lib/subagents.js +98 -1
  97. package/dist/lib/sync-manifest.d.ts +1 -1
  98. package/dist/lib/sync-manifest.js +3 -3
  99. package/dist/lib/teams/persistence.js +15 -5
  100. package/dist/lib/teams/registry.js +2 -2
  101. package/dist/lib/types.d.ts +75 -17
  102. package/dist/lib/types.js +3 -3
  103. package/dist/lib/usage.js +2 -2
  104. package/dist/lib/versions.d.ts +3 -0
  105. package/dist/lib/versions.js +158 -47
  106. package/dist/lib/workflows.d.ts +79 -0
  107. package/dist/lib/workflows.js +233 -0
  108. package/package.json +1 -5
  109. package/scripts/postinstall.js +60 -59
  110. package/dist/commands/fork.d.ts +0 -10
  111. package/dist/commands/fork.js +0 -146
@@ -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.');
@@ -55,6 +55,24 @@ export interface QueryOptions {
55
55
  export declare function getDB(): Database.Database;
56
56
  /** Close the cached database connection. */
57
57
  export declare function closeDB(): void;
58
+ /**
59
+ * Try to claim the right to run the incremental scan. Returns true if this
60
+ * process should proceed with scanning, false if another live process is
61
+ * already scanning (caller should skip the scan and serve from the DB).
62
+ *
63
+ * Uses the `meta` table so it survives crashes — dead PIDs are detected via
64
+ * process.kill(pid, 0), stale entries via TTL. No external lock files needed.
65
+ *
66
+ * Wrapped in db.transaction() (BEGIN IMMEDIATE) so the read-then-write is
67
+ * atomic and busy_timeout retries correctly — bare auto-commit DML in WAL
68
+ * mode can return SQLITE_BUSY_SNAPSHOT which bypasses the busy handler.
69
+ */
70
+ export declare function tryClaimScan(pid: number): boolean;
71
+ /**
72
+ * Release the scan claim written by tryClaimScan. Only deletes the entry
73
+ * if it still belongs to this process (guards against TTL takeovers).
74
+ */
75
+ export declare function releaseScan(pid: number): void;
58
76
  /** Return the absolute path to the sessions database file. */
59
77
  export declare function getDBPath(): string;
60
78
  /**
@@ -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,11 +151,17 @@ 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 30s instead of failing immediately on SQLITE_BUSY. Multiple
155
+ // agents (CLIs, skills, hooks) open this DB concurrently. The first scan of
156
+ // a new version home can take longer than 10s; concurrent callers need enough
157
+ // headroom to wait. The ledger-recheck in upsertSessionsBatch makes
158
+ // subsequent writers near-instant, so 30s is a rarely-reached safety net.
159
+ db.pragma('busy_timeout = 30000');
154
160
  db.exec(SCHEMA);
155
161
  const current = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
156
162
  const currentVersion = current ? parseInt(current.value, 10) : 0;
157
163
  if (!current) {
158
- db.prepare(`INSERT INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
164
+ db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
159
165
  }
160
166
  else if (currentVersion < SCHEMA_VERSION) {
161
167
  migrateSchema(db, currentVersion);
@@ -176,7 +182,7 @@ export function getDB() {
176
182
  }
177
183
  catch { /* ignore */ }
178
184
  }
179
- db.prepare(`INSERT INTO meta(key, value) VALUES ('legacy_indexes_removed', '1')`).run();
185
+ db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('legacy_indexes_removed', '1')`).run();
180
186
  }
181
187
  dbInstance = db;
182
188
  return db;
@@ -188,6 +194,75 @@ export function closeDB() {
188
194
  dbInstance = null;
189
195
  }
190
196
  }
197
+ // ---------------------------------------------------------------------------
198
+ // Scan coordinator — prevents concurrent full scans across processes
199
+ // ---------------------------------------------------------------------------
200
+ /** How long a scan claim is trusted before it's considered stale (ms). */
201
+ const SCAN_CLAIM_TTL_MS = 120_000; // 2 minutes
202
+ function isProcessAlive(pid) {
203
+ if (!pid || isNaN(pid))
204
+ return false;
205
+ try {
206
+ process.kill(pid, 0);
207
+ return true;
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ }
213
+ /**
214
+ * Try to claim the right to run the incremental scan. Returns true if this
215
+ * process should proceed with scanning, false if another live process is
216
+ * already scanning (caller should skip the scan and serve from the DB).
217
+ *
218
+ * Uses the `meta` table so it survives crashes — dead PIDs are detected via
219
+ * process.kill(pid, 0), stale entries via TTL. No external lock files needed.
220
+ *
221
+ * Wrapped in db.transaction() (BEGIN IMMEDIATE) so the read-then-write is
222
+ * atomic and busy_timeout retries correctly — bare auto-commit DML in WAL
223
+ * mode can return SQLITE_BUSY_SNAPSHOT which bypasses the busy handler.
224
+ */
225
+ export function tryClaimScan(pid) {
226
+ const db = getDB();
227
+ const txn = db.transaction(() => {
228
+ const existing = db
229
+ .prepare(`SELECT value FROM meta WHERE key = 'scan_in_progress'`)
230
+ .get();
231
+ if (existing) {
232
+ const parts = existing.value.split(':');
233
+ const existingPid = parseInt(parts[0], 10);
234
+ const existingTs = parseInt(parts[1], 10);
235
+ const ageMs = Date.now() - existingTs;
236
+ if (isProcessAlive(existingPid) && ageMs < SCAN_CLAIM_TTL_MS) {
237
+ return false; // another live process is scanning — skip
238
+ }
239
+ // Dead PID or expired TTL — take over below
240
+ }
241
+ db.prepare(`INSERT OR REPLACE INTO meta (key, value) VALUES ('scan_in_progress', ?)`)
242
+ .run(`${pid}:${Date.now()}`);
243
+ return true;
244
+ });
245
+ return txn();
246
+ }
247
+ /**
248
+ * Release the scan claim written by tryClaimScan. Only deletes the entry
249
+ * if it still belongs to this process (guards against TTL takeovers).
250
+ */
251
+ export function releaseScan(pid) {
252
+ const db = getDB();
253
+ const txn = db.transaction(() => {
254
+ const existing = db
255
+ .prepare(`SELECT value FROM meta WHERE key = 'scan_in_progress'`)
256
+ .get();
257
+ if (!existing)
258
+ return;
259
+ const claimPid = parseInt(existing.value.split(':')[0], 10);
260
+ if (claimPid === pid) {
261
+ db.prepare(`DELETE FROM meta WHERE key = 'scan_in_progress'`).run();
262
+ }
263
+ });
264
+ txn();
265
+ }
191
266
  /** Return the absolute path to the sessions database file. */
192
267
  export function getDBPath() {
193
268
  return DB_PATH;
@@ -362,8 +437,37 @@ export function upsertSessionsBatch(entries) {
362
437
  file_size = excluded.file_size,
363
438
  scanned_at = excluded.scanned_at
364
439
  `);
440
+ // Build a lookup from canonical file path → entry, used inside the write
441
+ // transaction to re-check the ledger AFTER acquiring the lock. When a
442
+ // concurrent process already committed the same files between our
443
+ // filterChangedFiles call and now, the ledger will have matching (mtime, size)
444
+ // rows — we skip those entries, making the second writer's transaction a
445
+ // near-instant no-op rather than redundant work.
446
+ const byPath = new Map(entries
447
+ .filter(e => e.scan && e.meta.filePath)
448
+ .map(e => [canonicalLedgerKey(e.meta.filePath), e]));
365
449
  const txn = db.transaction((items) => {
450
+ // Re-read the ledger now that we hold the write lock. Any file committed
451
+ // by a concurrent process since our pre-scan is visible here.
452
+ const CHUNK = 500; // stay under SQLite's 999-variable limit
453
+ const alreadyIndexed = new Set();
454
+ const paths = [...byPath.keys()];
455
+ for (let i = 0; i < paths.length; i += CHUNK) {
456
+ const chunk = paths.slice(i, i + CHUNK);
457
+ const phs = chunk.map(() => '?').join(',');
458
+ const rows = db
459
+ .prepare(`SELECT file_path, file_mtime_ms, file_size FROM scan_ledger WHERE file_path IN (${phs})`)
460
+ .all(...chunk);
461
+ for (const row of rows) {
462
+ const entry = byPath.get(row.file_path);
463
+ if (entry && row.file_mtime_ms === entry.scan.fileMtimeMs && row.file_size === entry.scan.fileSize) {
464
+ alreadyIndexed.add(entry.meta.id);
465
+ }
466
+ }
467
+ }
366
468
  for (const { meta, content, scan } of items) {
469
+ if (alreadyIndexed.has(meta.id))
470
+ continue;
367
471
  upsert.run({
368
472
  id: meta.id,
369
473
  short_id: meta.shortId,
@@ -37,6 +37,12 @@ export interface ScanProgress {
37
37
  /**
38
38
  * Discover sessions. Scans only files whose (mtime, size) have changed since
39
39
  * the last run; everything else is served from the SQLite cache.
40
+ *
41
+ * Only one process runs the incremental scan at a time. When many agents boot
42
+ * simultaneously (e.g. after a restart), the first to claim the scan slot does
43
+ * the work; the rest skip parsing entirely and serve from the DB. The claim is
44
+ * stored in the `meta` table — crash-safe via dead-PID detection and a 2-min
45
+ * TTL, no external lock files needed.
40
46
  */
41
47
  export declare function discoverSessions(options?: DiscoverOptions): Promise<SessionMeta[]>;
42
48
  /**
@@ -13,16 +13,22 @@ import * as crypto from 'crypto';
13
13
  import * as readline from 'readline';
14
14
  import { execFile } from 'child_process';
15
15
  import { promisify } from 'util';
16
- import { getAgentsDir } from '../state.js';
16
+ import { getAgentsDir, getHistoryDir } from '../state.js';
17
17
  const execFileAsync = promisify(execFile);
18
18
  import { AGENTS, 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';
22
22
  import { extractSessionTopic } from './prompt.js';
23
- import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, upsertSessionsBatch, querySessions, countSessions, ftsSearch, } from './db.js';
23
+ import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
24
24
  const HOME = os.homedir();
25
- const AGENTS_DIR = getAgentsDir();
25
+ // Versions can live under either repo: the user repo (current canonical
26
+ // location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
27
+ // ~/.agents-system/versions/). Both must be scanned — sessions written by
28
+ // any installed version end up in that version's projects/ dir, and the user
29
+ // can be running one repo's version while another repo holds older versions
30
+ // whose JSONLs the user still wants to search.
31
+ const VERSIONS_ROOTS = [getHistoryDir(), getAgentsDir()];
26
32
  const RUSH_SESSIONS_DIR = path.join(HOME, '.rush', 'sessions');
27
33
  const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
28
34
  /** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
@@ -32,24 +38,36 @@ const cachedAgentVersions = new Map();
32
38
  /**
33
39
  * Discover sessions. Scans only files whose (mtime, size) have changed since
34
40
  * the last run; everything else is served from the SQLite cache.
41
+ *
42
+ * Only one process runs the incremental scan at a time. When many agents boot
43
+ * simultaneously (e.g. after a restart), the first to claim the scan slot does
44
+ * the work; the rest skip parsing entirely and serve from the DB. The claim is
45
+ * stored in the `meta` table — crash-safe via dead-PID detection and a 2-min
46
+ * TTL, no external lock files needed.
35
47
  */
36
48
  export async function discoverSessions(options) {
37
49
  // Touch the DB so the schema is ready and connection is cached for this run.
38
50
  getDB();
39
51
  const agents = options?.agent ? [options.agent] : SESSION_AGENTS;
40
52
  const onProgress = options?.onProgress;
41
- // Incrementally re-scan changed files across all selected agents in parallel.
42
- await Promise.all(agents.map(agent => {
43
- switch (agent) {
44
- case 'claude': return scanClaudeIncremental(onProgress);
45
- case 'codex': return scanCodexIncremental(onProgress);
46
- case 'gemini': return scanGeminiIncremental(onProgress);
47
- case 'opencode': return scanOpenCodeIncremental();
48
- case 'openclaw': return scanOpenClawIncremental();
49
- case 'rush': return scanRushIncremental(onProgress);
50
- case 'hermes': return scanHermesIncremental(onProgress);
53
+ if (tryClaimScan(process.pid)) {
54
+ try {
55
+ await Promise.all(agents.map(agent => {
56
+ switch (agent) {
57
+ case 'claude': return scanClaudeIncremental(onProgress);
58
+ case 'codex': return scanCodexIncremental(onProgress);
59
+ case 'gemini': return scanGeminiIncremental(onProgress);
60
+ case 'opencode': return scanOpenCodeIncremental();
61
+ case 'openclaw': return scanOpenClawIncremental();
62
+ case 'rush': return scanRushIncremental(onProgress);
63
+ case 'hermes': return scanHermesIncremental(onProgress);
64
+ }
65
+ }));
51
66
  }
52
- }));
67
+ finally {
68
+ releaseScan(process.pid);
69
+ }
70
+ }
53
71
  const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
54
72
  return sessions;
55
73
  }
@@ -184,8 +202,10 @@ export function getAgentSessionDirs(agent, subdir) {
184
202
  dirs.push(dir);
185
203
  }
186
204
  addDir(path.join(HOME, `.${agent}`, subdir));
187
- const versionsBase = path.join(AGENTS_DIR, 'versions', agent);
188
- if (fs.existsSync(versionsBase)) {
205
+ for (const root of VERSIONS_ROOTS) {
206
+ const versionsBase = path.join(root, 'versions', agent);
207
+ if (!fs.existsSync(versionsBase))
208
+ continue;
189
209
  try {
190
210
  for (const version of fs.readdirSync(versionsBase)) {
191
211
  addDir(path.join(versionsBase, version, 'home', `.${agent}`, subdir));
@@ -193,7 +213,7 @@ export function getAgentSessionDirs(agent, subdir) {
193
213
  }
194
214
  catch { /* dir unreadable */ }
195
215
  }
196
- const backupsBase = path.join(AGENTS_DIR, 'backups', agent);
216
+ const backupsBase = path.join(getHistoryDir(), 'backups', agent);
197
217
  if (fs.existsSync(backupsBase)) {
198
218
  try {
199
219
  for (const ts of fs.readdirSync(backupsBase)) {
@@ -219,8 +239,10 @@ function getClaudeAccount() {
219
239
  path.join(HOME, '.claude', '.claude.json'),
220
240
  path.join(HOME, '.claude.json'),
221
241
  ];
222
- const versionsBase = path.join(AGENTS_DIR, 'versions', 'claude');
223
- if (fs.existsSync(versionsBase)) {
242
+ for (const root of VERSIONS_ROOTS) {
243
+ const versionsBase = path.join(root, 'versions', 'claude');
244
+ if (!fs.existsSync(versionsBase))
245
+ continue;
224
246
  try {
225
247
  for (const version of fs.readdirSync(versionsBase)) {
226
248
  candidates.push(path.join(versionsBase, version, 'home', '.claude', '.claude.json'));
@@ -405,8 +427,10 @@ function getCodexAccount() {
405
427
  if (cachedCodexAccount !== undefined)
406
428
  return cachedCodexAccount || undefined;
407
429
  const candidates = [path.join(HOME, '.codex', 'auth.json')];
408
- const versionsBase = path.join(AGENTS_DIR, 'versions', 'codex');
409
- if (fs.existsSync(versionsBase)) {
430
+ for (const root of VERSIONS_ROOTS) {
431
+ const versionsBase = path.join(root, 'versions', 'codex');
432
+ if (!fs.existsSync(versionsBase))
433
+ continue;
410
434
  try {
411
435
  for (const version of fs.readdirSync(versionsBase)) {
412
436
  candidates.push(path.join(versionsBase, version, 'home', '.codex', 'auth.json'));
@@ -1451,22 +1475,24 @@ function normalizeVersion(version) {
1451
1475
  const trimmed = version?.trim();
1452
1476
  return trimmed ? trimmed : undefined;
1453
1477
  }
1454
- /** Extract the version number from a managed ~/.agents/versions/<agent>/<version>/... path. */
1478
+ /** Extract the version number from a managed versions/<agent>/<version>/... path under either repo. */
1455
1479
  function extractVersionFromManagedPath(agent, sourcePath) {
1456
1480
  if (!sourcePath)
1457
1481
  return undefined;
1458
1482
  const candidates = [sourcePath, safeRealpathSync(sourcePath) || ''];
1459
- const marker = `/.agents/versions/${agent}/`;
1483
+ const markers = [`/.agents/versions/${agent}/`, `/.agents-system/versions/${agent}/`];
1460
1484
  for (const candidate of candidates) {
1461
1485
  if (!candidate)
1462
1486
  continue;
1463
1487
  const normalized = candidate.split(path.sep).join('/');
1464
- const start = normalized.indexOf(marker);
1465
- if (start === -1)
1466
- continue;
1467
- const version = normalized.slice(start + marker.length).split('/')[0];
1468
- if (version)
1469
- return version;
1488
+ for (const marker of markers) {
1489
+ const start = normalized.indexOf(marker);
1490
+ if (start === -1)
1491
+ continue;
1492
+ const version = normalized.slice(start + marker.length).split('/')[0];
1493
+ if (version)
1494
+ return version;
1495
+ }
1470
1496
  }
1471
1497
  return undefined;
1472
1498
  }
@@ -9,12 +9,12 @@
9
9
  import * as fs from 'fs';
10
10
  import * as os from 'os';
11
11
  import * as path from 'path';
12
- import { getAgentsDir } from '../state.js';
12
+ import { getTeamsAgentsDir } from '../state.js';
13
13
  const HOME = os.homedir();
14
14
  // Default path; tests can override via AGENTS_TEAMS_DIR env var.
15
15
  /** Resolve the directory containing per-session team metadata files. */
16
16
  function teamsAgentsDir() {
17
- return process.env.AGENTS_TEAMS_DIR ?? path.join(getAgentsDir(), 'teams', 'agents');
17
+ return process.env.AGENTS_TEAMS_DIR ?? getTeamsAgentsDir();
18
18
  }
19
19
  /**
20
20
  * Determine whether `session` was spawned by `agents teams`.
@@ -13,15 +13,6 @@ export interface ConflictInfo {
13
13
  version: string;
14
14
  conflicts: string[];
15
15
  }
16
- /**
17
- * Detect conflicting files between source and destination directories.
18
- * Returns list of filenames that exist in both locations (excluding symlinks in dest).
19
- */
20
- export declare function detectConflicts(src: string, dest: string, prefix?: string): string[];
21
- /**
22
- * Prompt user for conflict resolution strategy.
23
- */
24
- export declare function promptConflictStrategy(conflictInfos: ConflictInfo[]): Promise<ConflictStrategy | null>;
25
16
  /**
26
17
  * Generate the shim script content for an agent.
27
18
  *
@@ -57,8 +48,10 @@ export declare function promptConflictStrategy(conflictInfos: ConflictInfo[]): P
57
48
  * and capability flag `memoryImports` → `rulesImports`.
58
49
  * v8 — versions moved from ~/.agents-system/versions to ~/.agents/versions
59
50
  * (two-repo split: system = shipped defaults, user = operational state).
51
+ * v9 — claude shim exports CLAUDE_CODE_OAUTH_TOKEN from per-version
52
+ * .oauth_token file on Linux (keychain-less sandbox fallback).
60
53
  */
61
- export declare const SHIM_SCHEMA_VERSION = 8;
54
+ export declare const SHIM_SCHEMA_VERSION = 10;
62
55
  /**
63
56
  * Generate the full bash shim script for the given agent. The returned string
64
57
  * is written to ~/.agents/shims/{cliCommand} and made executable.
@@ -90,7 +83,7 @@ export declare function removeShim(agent: AgentId): boolean;
90
83
  * v6 — versions moved from ~/.agents-system/versions to ~/.agents/versions
91
84
  * (two-repo split: system = shipped defaults, user = operational state).
92
85
  */
93
- export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 6;
86
+ export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 7;
94
87
  /**
95
88
  * Generate a versioned alias script that directly execs a specific version.
96
89
  * e.g., claude@2.0.65 -> directly runs that version's binary
@@ -128,14 +121,6 @@ export declare function removeVersionedAlias(agent: AgentId, version: string): b
128
121
  * Check if a versioned alias exists.
129
122
  */
130
123
  export declare function versionedAliasExists(agent: AgentId, version: string): boolean;
131
- /**
132
- * Detect conflicts that would occur when switching config symlink for an agent/version.
133
- * This allows collecting conflicts upfront before prompting for a strategy.
134
- *
135
- * Returns null if no migration is needed (already symlink or doesn't exist),
136
- * or ConflictInfo with the list of conflicting files.
137
- */
138
- export declare function detectMigrationConflicts(agent: AgentId, version: string): ConflictInfo | null;
139
124
  /**
140
125
  * Switch the agent's config symlink to point to a specific version.
141
126
  * e.g., ~/.claude -> ~/.agents/versions/claude/2.0.65/home/.claude/
@@ -185,14 +170,6 @@ export declare function switchHomeFileSymlinks(agent: AgentId, version: string):
185
170
  * - Symlink points elsewhere: replace it.
186
171
  */
187
172
  export declare function ensureClaudeInsideSymlink(version: string): void;
188
- /**
189
- * Apply `ensureClaudeInsideSymlink` to every installed Claude version.
190
- * Safe to call repeatedly; per-version calls are idempotent.
191
- */
192
- export declare function ensureAllClaudeInsideSymlinks(): {
193
- migrated: string[];
194
- errors: string[];
195
- };
196
173
  /**
197
174
  * Get the current config symlink target version, if any.
198
175
  */
@@ -201,17 +178,6 @@ export declare function getConfigSymlinkVersion(agent: AgentId): string | null;
201
178
  * Check if shim exists for an agent.
202
179
  */
203
180
  export declare function shimExists(agent: AgentId): boolean;
204
- /**
205
- * Read the schema version embedded in an existing on-disk shim. Returns
206
- * `null` if the shim doesn't exist or has no version marker (pre-v2 shim).
207
- */
208
- export declare function readShimSchemaVersion(agent: AgentId): number | null;
209
- /**
210
- * True if the on-disk shim's schema version matches `SHIM_SCHEMA_VERSION`.
211
- * False means either the shim is missing, is pre-v2 (no marker), or is an
212
- * older version that needs regeneration.
213
- */
214
- export declare function isShimCurrent(agent: AgentId): boolean;
215
181
  /**
216
182
  * Regenerate the shim if it's missing or outdated. Returns a status describing
217
183
  * what happened — callers can surface a one-line notice to the user ("Updated
@@ -276,10 +242,6 @@ export declare function addShimsToPath(overrides?: {
276
242
  error?: string;
277
243
  };
278
244
  export declare function listAgentsWithInstalledVersions(): AgentId[];
279
- /**
280
- * Create shims for all installed agents.
281
- */
282
- export declare function ensureAllShims(): void;
283
245
  /**
284
246
  * Resource diff between two versions. Each field lists resources present in
285
247
  * the current version but missing from the target.
@@ -295,17 +257,7 @@ export interface ResourceDiff {
295
257
  }[];
296
258
  mcp: string[];
297
259
  }
298
- /**
299
- * Compare resources between two versions.
300
- * Returns resources that exist in currentVersion but not in targetVersion.
301
- */
302
- export declare function compareVersionResources(agent: AgentId, currentVersion: string, targetVersion: string): ResourceDiff;
303
260
  /**
304
261
  * Check if a ResourceDiff has any differences.
305
262
  */
306
263
  export declare function hasResourceDiff(diff: ResourceDiff): boolean;
307
- /**
308
- * Copy resources from one version to another.
309
- * Only copies resources listed in the diff (i.e., ones missing in target).
310
- */
311
- export declare function copyResourcesToVersion(agent: AgentId, fromVersion: string, toVersion: string, diff: ResourceDiff): void;