@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.
- package/CHANGELOG.md +143 -39
- package/README.md +6 -6
- package/dist/commands/alias.js +2 -2
- package/dist/commands/browser-picker.d.ts +21 -0
- package/dist/commands/browser-picker.js +114 -0
- package/dist/commands/browser.js +793 -83
- package/dist/commands/cloud.js +8 -0
- package/dist/commands/commands.js +72 -22
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/exec.js +70 -1
- package/dist/commands/hooks.js +71 -26
- package/dist/commands/mcp.js +81 -39
- package/dist/commands/plugins.js +224 -17
- package/dist/commands/prune.js +29 -1
- package/dist/commands/pull.js +3 -3
- package/dist/commands/repo.js +1 -1
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.js +154 -20
- package/dist/commands/sessions.js +62 -19
- package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
- package/dist/commands/{init.js → setup.js} +22 -21
- package/dist/commands/skills.js +60 -19
- package/dist/commands/subagents.js +41 -13
- package/dist/commands/utils.d.ts +16 -0
- package/dist/commands/utils.js +32 -0
- package/dist/commands/view.js +78 -20
- package/dist/commands/workflows.d.ts +10 -0
- package/dist/commands/workflows.js +457 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +48 -36
- package/dist/lib/agents.js +2 -2
- package/dist/lib/auto-pull-worker.js +2 -3
- package/dist/lib/auto-pull.js +2 -2
- package/dist/lib/browser/cdp.d.ts +7 -1
- package/dist/lib/browser/cdp.js +32 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +41 -3
- package/dist/lib/browser/devices.d.ts +4 -0
- package/dist/lib/browser/devices.js +27 -0
- package/dist/lib/browser/drivers/local.js +22 -6
- package/dist/lib/browser/drivers/ssh.js +9 -2
- package/dist/lib/browser/input.d.ts +1 -0
- package/dist/lib/browser/input.js +3 -0
- package/dist/lib/browser/ipc.js +158 -23
- package/dist/lib/browser/profiles.d.ts +10 -2
- package/dist/lib/browser/profiles.js +122 -37
- package/dist/lib/browser/service.d.ts +91 -13
- package/dist/lib/browser/service.js +767 -132
- package/dist/lib/browser/types.d.ts +91 -3
- package/dist/lib/browser/types.js +16 -0
- package/dist/lib/cloud/rush.d.ts +28 -1
- package/dist/lib/cloud/rush.js +69 -14
- package/dist/lib/cloud/store.js +2 -2
- package/dist/lib/commands.d.ts +1 -15
- package/dist/lib/commands.js +11 -7
- package/dist/lib/daemon.js +2 -3
- package/dist/lib/doctor-diff.js +4 -4
- package/dist/lib/events.js +2 -2
- package/dist/lib/hooks.d.ts +11 -7
- package/dist/lib/hooks.js +138 -49
- package/dist/lib/migrate.d.ts +1 -1
- package/dist/lib/migrate.js +1237 -22
- package/dist/lib/models.js +2 -2
- package/dist/lib/permissions.d.ts +8 -66
- package/dist/lib/permissions.js +18 -18
- package/dist/lib/plugins.d.ts +94 -24
- package/dist/lib/plugins.js +702 -123
- package/dist/lib/pty-server.js +9 -10
- package/dist/lib/resource-patterns.d.ts +41 -0
- package/dist/lib/resource-patterns.js +82 -0
- package/dist/lib/resources/hooks.d.ts +5 -1
- package/dist/lib/resources/hooks.js +21 -4
- package/dist/lib/resources/index.d.ts +17 -0
- package/dist/lib/resources/index.js +7 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/resources/workflows.d.ts +24 -0
- package/dist/lib/resources/workflows.js +110 -0
- package/dist/lib/resources.d.ts +6 -1
- package/dist/lib/resources.js +12 -2
- package/dist/lib/rotate.js +3 -4
- package/dist/lib/session/active.d.ts +3 -0
- package/dist/lib/session/active.js +92 -6
- package/dist/lib/session/cloud.js +2 -2
- package/dist/lib/session/db.d.ts +18 -0
- package/dist/lib/session/db.js +109 -5
- package/dist/lib/session/discover.d.ts +6 -0
- package/dist/lib/session/discover.js +55 -29
- package/dist/lib/session/team-filter.js +2 -2
- package/dist/lib/shims.d.ts +4 -52
- package/dist/lib/shims.js +23 -15
- package/dist/lib/skills.js +6 -2
- package/dist/lib/sqlite.js +10 -4
- package/dist/lib/state.d.ts +101 -16
- package/dist/lib/state.js +179 -31
- package/dist/lib/subagents.d.ts +28 -0
- package/dist/lib/subagents.js +98 -1
- package/dist/lib/sync-manifest.d.ts +1 -1
- package/dist/lib/sync-manifest.js +3 -3
- package/dist/lib/teams/persistence.js +15 -5
- package/dist/lib/teams/registry.js +2 -2
- package/dist/lib/types.d.ts +75 -17
- package/dist/lib/types.js +3 -3
- package/dist/lib/usage.js +2 -2
- package/dist/lib/versions.d.ts +3 -0
- package/dist/lib/versions.js +158 -47
- package/dist/lib/workflows.d.ts +79 -0
- package/dist/lib/workflows.js +233 -0
- package/package.json +1 -5
- package/scripts/postinstall.js +60 -59
- package/dist/commands/fork.d.ts +0 -10
- 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/
|
|
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 {
|
|
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(
|
|
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(
|
|
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
|
|
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 {
|
|
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(
|
|
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.');
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/lib/session/db.js
CHANGED
|
@@ -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 {
|
|
13
|
-
const SESSIONS_DIR =
|
|
14
|
-
const DB_PATH =
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
188
|
-
|
|
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(
|
|
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
|
|
223
|
-
|
|
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
|
|
409
|
-
|
|
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
|
|
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
|
|
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
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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 {
|
|
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 ??
|
|
17
|
+
return process.env.AGENTS_TEAMS_DIR ?? getTeamsAgentsDir();
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* Determine whether `session` was spawned by `agents teams`.
|
package/dist/lib/shims.d.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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;
|