@phnx-labs/agents-cli 1.20.27 → 1.20.29
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 +3 -0
- package/dist/commands/doctor.js +57 -4
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +198 -15
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +108 -29
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +8 -24
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +3 -1
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +44 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- package/dist/lib/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/doctor-diff.d.ts +12 -0
- package/dist/lib/doctor-diff.js +89 -2
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugins.d.ts +10 -0
- package/dist/lib/plugins.js +1 -1
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +24 -0
- package/dist/lib/session/db.js +80 -5
- package/dist/lib/session/discover.d.ts +28 -0
- package/dist/lib/session/discover.js +303 -4
- package/dist/lib/session/parse.d.ts +7 -0
- package/dist/lib/session/parse.js +110 -0
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +31 -3
- package/dist/lib/session/remote.js +121 -14
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/types.d.ts +21 -0
- package/dist/lib/versions.d.ts +6 -2
- package/dist/lib/versions.js +8 -4
- package/package.json +1 -1
- package/scripts/postinstall.js +62 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote secrets — read and use `agents secrets` bundles that live on another
|
|
3
|
+
* host, over the same hardened SSH path that `agents secrets export --host`
|
|
4
|
+
* (the write inverse) already uses.
|
|
5
|
+
*
|
|
6
|
+
* This is the READ / USE direction:
|
|
7
|
+
* - browse: drive the remote `agents secrets list|view` and stream its
|
|
8
|
+
* stdout back verbatim (lossless, no parsing).
|
|
9
|
+
* - use: resolve a remote bundle to an env map (JSON over ssh stdout) and
|
|
10
|
+
* inject it ephemerally — never written to this machine's keychain.
|
|
11
|
+
*
|
|
12
|
+
* Trust model: relies on the operator's existing SSH access to the host (same
|
|
13
|
+
* boundary as `export --host` / `run --host`). Bundle names are shell-quoted
|
|
14
|
+
* into the remote command; resolved VALUES return over ssh stdout; a forwarded
|
|
15
|
+
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
|
+
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
|
+
*/
|
|
18
|
+
import { sshExec, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
|
+
import { resolveHost } from '../hosts/registry.js';
|
|
20
|
+
import { sshTargetFor } from '../hosts/types.js';
|
|
21
|
+
const REMOTE_TIMEOUT_MS = 30_000;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
|
|
24
|
+
* registry first (enrolled name → ssh-config alias / `user@host`); on a miss,
|
|
25
|
+
* treats the value as a raw ssh target and validates it against injection.
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveSshTarget(nameOrAlias) {
|
|
28
|
+
const host = await resolveHost(nameOrAlias);
|
|
29
|
+
if (host)
|
|
30
|
+
return sshTargetFor(host);
|
|
31
|
+
assertValidSshTarget(nameOrAlias);
|
|
32
|
+
return nameOrAlias;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Merge `--host <single>` and `--hosts <a,b,c>` into an ordered, de-duplicated
|
|
36
|
+
* list. Both flags compose; either alone works. Empty when neither is set.
|
|
37
|
+
*/
|
|
38
|
+
export function parseHostsOption(opts) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const push = (h) => {
|
|
42
|
+
const t = h.trim();
|
|
43
|
+
if (t && !seen.has(t)) {
|
|
44
|
+
seen.add(t);
|
|
45
|
+
out.push(t);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
if (opts.host)
|
|
49
|
+
push(opts.host);
|
|
50
|
+
if (opts.hosts)
|
|
51
|
+
for (const h of opts.hosts.split(','))
|
|
52
|
+
push(h);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Split a `bundle@host` reference. No `@` → a local bundle (host undefined).
|
|
57
|
+
* Bundle names can't contain `@` (BUNDLE_NAME_PATTERN), so the FIRST `@`
|
|
58
|
+
* separates the bundle from the ssh target — and the target itself may be a
|
|
59
|
+
* `user@host` (e.g. `r2.backups@muqsit@box` → bundle `r2.backups`, host
|
|
60
|
+
* `muqsit@box`).
|
|
61
|
+
*/
|
|
62
|
+
export function splitBundleRef(ref) {
|
|
63
|
+
const at = ref.indexOf('@');
|
|
64
|
+
if (at === -1)
|
|
65
|
+
return { bundle: ref };
|
|
66
|
+
const bundle = ref.slice(0, at);
|
|
67
|
+
const host = ref.slice(at + 1);
|
|
68
|
+
if (!bundle || !host) {
|
|
69
|
+
throw new Error(`Invalid remote bundle reference ${JSON.stringify(ref)}. Expected 'bundle@host'.`);
|
|
70
|
+
}
|
|
71
|
+
return { bundle, host };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run `agents secrets <args>` on a remote host over ssh and return the raw
|
|
75
|
+
* result. Used by the browse commands — the remote's human-readable stdout is
|
|
76
|
+
* streamed back unchanged. `tty` forces an interactive ssh session (`-tt`) so a
|
|
77
|
+
* remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
|
|
78
|
+
*/
|
|
79
|
+
export function remoteSecretsRaw(target, args, opts = {}) {
|
|
80
|
+
const inner = ['agents', 'secrets', ...args].map(shellQuote).join(' ');
|
|
81
|
+
const remoteCmd = `bash -lc ${shellQuote(inner)}`;
|
|
82
|
+
return sshExec(target, remoteCmd, {
|
|
83
|
+
timeoutMs: REMOTE_TIMEOUT_MS,
|
|
84
|
+
input: opts.input,
|
|
85
|
+
extraSshArgs: opts.tty ? ['-tt'] : undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
90
|
+
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
91
|
+
* ssh stdout (encrypted in transit), parsed in memory, never persisted.
|
|
92
|
+
*
|
|
93
|
+
* The remote unlocks the bundle with ITS OWN credentials — the owner host's
|
|
94
|
+
* keychain/secrets-agent, or its own `AGENTS_SECRETS_PASSPHRASE` (in the login
|
|
95
|
+
* env) for a file-backed bundle. We deliberately do NOT forward this machine's
|
|
96
|
+
* passphrase: the remote bundle is encrypted with the remote's passphrase, so
|
|
97
|
+
* overriding it would break the read. (A macOS remote under non-interactive
|
|
98
|
+
* SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
|
|
99
|
+
* an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
|
|
100
|
+
*/
|
|
101
|
+
export async function remoteResolveEnv(target, bundle) {
|
|
102
|
+
assertValidSshTarget(target);
|
|
103
|
+
const exportCmd = `agents secrets export ${shellQuote(bundle)} --plaintext --format json`;
|
|
104
|
+
const res = sshExec(target, `bash -lc ${shellQuote(exportCmd)}`, {
|
|
105
|
+
timeoutMs: REMOTE_TIMEOUT_MS,
|
|
106
|
+
});
|
|
107
|
+
if (res.code !== 0) {
|
|
108
|
+
const msg = (res.stderr || res.stdout || '').trim();
|
|
109
|
+
const why = res.timedOut ? 'timed out' : res.code === null ? 'ssh failed' : `exit ${res.code}`;
|
|
110
|
+
throw new Error(`Failed to resolve '${bundle}' on ${target} (${why})${msg ? `: ${msg}` : ''}`);
|
|
111
|
+
}
|
|
112
|
+
// Tolerate login-shell banner noise on stdout: take the outer { … } object.
|
|
113
|
+
const raw = res.stdout;
|
|
114
|
+
const start = raw.indexOf('{');
|
|
115
|
+
const end = raw.lastIndexOf('}');
|
|
116
|
+
const jsonText = start >= 0 && end >= start ? raw.slice(start, end + 1) : raw.trim();
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(jsonText);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new Error(`Could not parse secrets JSON from '${bundle}' on ${target}. ` +
|
|
123
|
+
`Is the remote agents-cli new enough for 'secrets export --format json'?`);
|
|
124
|
+
}
|
|
125
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
126
|
+
throw new Error(`Unexpected payload resolving '${bundle}' on ${target}.`);
|
|
127
|
+
}
|
|
128
|
+
const env = {};
|
|
129
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
130
|
+
env[k] = typeof v === 'string' ? v : String(v);
|
|
131
|
+
}
|
|
132
|
+
return env;
|
|
133
|
+
}
|
package/dist/lib/self-update.js
CHANGED
|
@@ -16,6 +16,7 @@ import * as os from 'os';
|
|
|
16
16
|
import * as path from 'path';
|
|
17
17
|
import { spawnSync } from 'child_process';
|
|
18
18
|
import { compareVersions } from './versions.js';
|
|
19
|
+
import { needsWindowsShell } from './platform/index.js';
|
|
19
20
|
export const NPM_PACKAGE_NAME = '@phnx-labs/agents-cli';
|
|
20
21
|
/**
|
|
21
22
|
* The directory bun installs global packages into:
|
|
@@ -145,7 +146,10 @@ export async function installPackageIntoPrefix(spec, prefix) {
|
|
|
145
146
|
const { execFile } = await import('child_process');
|
|
146
147
|
const { promisify } = await import('util');
|
|
147
148
|
const execFileAsync = promisify(execFile);
|
|
148
|
-
|
|
149
|
+
// On Windows `npm` is `npm.cmd`; execFile cannot run it without a shell (ENOENT).
|
|
150
|
+
await execFileAsync('npm', ['install', '-g', '--prefix', prefix, spec, '--ignore-scripts'], {
|
|
151
|
+
shell: needsWindowsShell('npm'),
|
|
152
|
+
});
|
|
149
153
|
}
|
|
150
154
|
/**
|
|
151
155
|
* Install `spec` into bun's global store with `bun add -g`. bun writes to
|
|
@@ -159,7 +163,8 @@ export async function installPackageWithBun(spec) {
|
|
|
159
163
|
const { execFile } = await import('child_process');
|
|
160
164
|
const { promisify } = await import('util');
|
|
161
165
|
const execFileAsync = promisify(execFile);
|
|
162
|
-
|
|
166
|
+
// On Windows `bun` resolves to `bun.exe`/`bun.cmd`; force shell for the .cmd case.
|
|
167
|
+
await execFileAsync('bun', ['add', '-g', spec], { shell: needsWindowsShell('bun') });
|
|
163
168
|
}
|
|
164
169
|
/** Read the version field of the package.json at `packageRoot`, fresh from disk. */
|
|
165
170
|
export function readInstalledVersion(packageRoot) {
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface SessionRow {
|
|
|
35
35
|
export interface ScanStamp {
|
|
36
36
|
fileMtimeMs: number;
|
|
37
37
|
fileSize: number;
|
|
38
|
+
scannedAt?: number;
|
|
38
39
|
}
|
|
39
40
|
/** Filter and pagination options for querying the sessions table. */
|
|
40
41
|
export interface QueryOptions {
|
|
@@ -45,6 +46,10 @@ export interface QueryOptions {
|
|
|
45
46
|
/** Match any session whose cwd equals this or is a descendant of it. */
|
|
46
47
|
cwdPrefix?: string;
|
|
47
48
|
project?: string;
|
|
49
|
+
/** Match the full session id or short id, case-insensitively (exact). */
|
|
50
|
+
idExact?: string;
|
|
51
|
+
/** Match sessions whose id or short id begins with this (case-insensitive prefix). */
|
|
52
|
+
idPrefix?: string;
|
|
48
53
|
sinceMs?: number;
|
|
49
54
|
untilMs?: number;
|
|
50
55
|
limit?: number;
|
|
@@ -118,6 +123,16 @@ export declare function upsertSessionsBatch(entries: Array<{
|
|
|
118
123
|
* Leaves FTS5 content/topic/project untouched — cheap to call every run.
|
|
119
124
|
*/
|
|
120
125
|
export declare function syncLabels(labelMap: Map<string, string | null>): number;
|
|
126
|
+
/**
|
|
127
|
+
* Sync topics (session titles) for a set of sessions, keyed by id. For agents
|
|
128
|
+
* whose human-readable title lives in a side index that updates independently
|
|
129
|
+
* of the transcript (Codex `session_index.jsonl`), the per-file scan can't see
|
|
130
|
+
* a title that lands later. This applies those titles by id, updating both
|
|
131
|
+
* `sessions.topic` and the FTS5 topic column. Only ever sets a non-empty title
|
|
132
|
+
* and only when it differs from the stored value — cheap to call every run.
|
|
133
|
+
* Returns the number of rows updated.
|
|
134
|
+
*/
|
|
135
|
+
export declare function syncTopics(topicMap: Map<string, string>): number;
|
|
121
136
|
/** Query sessions from the database, applying filters and ordering by timestamp descending. */
|
|
122
137
|
export declare function querySessions(options?: QueryOptions): SessionMeta[];
|
|
123
138
|
/** Count sessions matching the given filter options. */
|
|
@@ -160,6 +175,15 @@ export declare function getAllFilePaths(): Set<string>;
|
|
|
160
175
|
export declare function getSessionsByFilePaths(paths: string[]): Map<string, SessionMeta>;
|
|
161
176
|
/** Look up a single session by its unique ID. */
|
|
162
177
|
export declare function getSessionById(id: string): SessionMeta | null;
|
|
178
|
+
/**
|
|
179
|
+
* Resolve a full-or-partial session id against the index, exact-first then
|
|
180
|
+
* prefix — the DB-backed equivalent of resolveSessionById() that runs over the
|
|
181
|
+
* SQLite table instead of a pre-loaded array. Matches both the full id and the
|
|
182
|
+
* short id. An exact hit short-circuits so a complete id never also drags in its
|
|
183
|
+
* prefix siblings. `scope` narrows by agent / version / project (cwd) so an
|
|
184
|
+
* ambiguous prefix disambiguates against the caller's context.
|
|
185
|
+
*/
|
|
186
|
+
export declare function findSessionsById(idQuery: string, scope?: Pick<QueryOptions, 'agent' | 'version' | 'cwd' | 'project'>): SessionMeta[];
|
|
163
187
|
/** A single full-text search result with ranking score. */
|
|
164
188
|
export interface FtsHit {
|
|
165
189
|
sessionId: string;
|
package/dist/lib/session/db.js
CHANGED
|
@@ -65,6 +65,7 @@ CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
|
|
|
65
65
|
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
66
66
|
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
|
|
67
67
|
CREATE INDEX IF NOT EXISTS idx_sessions_file_path ON sessions(file_path);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_short_id ON sessions(short_id);
|
|
68
69
|
|
|
69
70
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
|
|
70
71
|
session_id UNINDEXED,
|
|
@@ -289,9 +290,9 @@ export function getDBPath() {
|
|
|
289
290
|
export function getScanStampByPath(filePath) {
|
|
290
291
|
const db = getDB();
|
|
291
292
|
const row = db
|
|
292
|
-
.prepare(`SELECT file_mtime_ms, file_size FROM scan_ledger WHERE file_path = ? LIMIT 1`)
|
|
293
|
+
.prepare(`SELECT file_mtime_ms, file_size, scanned_at FROM scan_ledger WHERE file_path = ? LIMIT 1`)
|
|
293
294
|
.get(canonicalLedgerKey(filePath));
|
|
294
|
-
return row ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size } : null;
|
|
295
|
+
return row ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at } : null;
|
|
295
296
|
}
|
|
296
297
|
/**
|
|
297
298
|
* Bulk-load the stamp ledger for a set of file paths in a single SQL query.
|
|
@@ -323,13 +324,13 @@ export function getScanStampsForPaths(filePaths) {
|
|
|
323
324
|
const placeholders = chunk.map(() => '?').join(',');
|
|
324
325
|
const rows = db
|
|
325
326
|
.prepare(`
|
|
326
|
-
SELECT file_path, file_mtime_ms, file_size
|
|
327
|
+
SELECT file_path, file_mtime_ms, file_size, scanned_at
|
|
327
328
|
FROM scan_ledger
|
|
328
329
|
WHERE file_path IN (${placeholders})
|
|
329
330
|
`)
|
|
330
331
|
.all(...chunk);
|
|
331
332
|
for (const row of rows) {
|
|
332
|
-
const stamp = { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size };
|
|
333
|
+
const stamp = { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at };
|
|
333
334
|
for (const original of canonicalToOriginals.get(row.file_path) || []) {
|
|
334
335
|
result.set(original, stamp);
|
|
335
336
|
}
|
|
@@ -558,6 +559,48 @@ export function syncLabels(labelMap) {
|
|
|
558
559
|
txn(updates);
|
|
559
560
|
return updates.length;
|
|
560
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Sync topics (session titles) for a set of sessions, keyed by id. For agents
|
|
564
|
+
* whose human-readable title lives in a side index that updates independently
|
|
565
|
+
* of the transcript (Codex `session_index.jsonl`), the per-file scan can't see
|
|
566
|
+
* a title that lands later. This applies those titles by id, updating both
|
|
567
|
+
* `sessions.topic` and the FTS5 topic column. Only ever sets a non-empty title
|
|
568
|
+
* and only when it differs from the stored value — cheap to call every run.
|
|
569
|
+
* Returns the number of rows updated.
|
|
570
|
+
*/
|
|
571
|
+
export function syncTopics(topicMap) {
|
|
572
|
+
if (topicMap.size === 0)
|
|
573
|
+
return 0;
|
|
574
|
+
const db = getDB();
|
|
575
|
+
const ids = [...topicMap.keys()];
|
|
576
|
+
const CHUNK = 500;
|
|
577
|
+
const updates = [];
|
|
578
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
579
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
580
|
+
const placeholders = chunk.map(() => '?').join(',');
|
|
581
|
+
const rows = db
|
|
582
|
+
.prepare(`SELECT id, topic FROM sessions WHERE id IN (${placeholders})`)
|
|
583
|
+
.all(...chunk);
|
|
584
|
+
for (const row of rows) {
|
|
585
|
+
const live = topicMap.get(row.id) ?? '';
|
|
586
|
+
if (live && live !== (row.topic ?? '')) {
|
|
587
|
+
updates.push({ id: row.id, topic: live });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (updates.length === 0)
|
|
592
|
+
return 0;
|
|
593
|
+
const updSessions = db.prepare(`UPDATE sessions SET topic = ? WHERE id = ?`);
|
|
594
|
+
const updFts = db.prepare(`UPDATE session_text SET topic = ? WHERE session_id = ?`);
|
|
595
|
+
const txn = db.transaction((items) => {
|
|
596
|
+
for (const { id, topic } of items) {
|
|
597
|
+
updSessions.run(topic, id);
|
|
598
|
+
updFts.run(topic, id);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
txn(updates);
|
|
602
|
+
return updates.length;
|
|
603
|
+
}
|
|
561
604
|
/** Convert a raw database row into a SessionMeta object. */
|
|
562
605
|
function rowToMeta(row) {
|
|
563
606
|
return {
|
|
@@ -601,13 +644,28 @@ function buildSessionWhere(options) {
|
|
|
601
644
|
params.push(options.cwd);
|
|
602
645
|
}
|
|
603
646
|
if (options.cwdPrefix) {
|
|
647
|
+
// Stored cwd uses the host path separator (normalizeCwd → path.resolve), so
|
|
648
|
+
// the subdir wildcard must too — a hardcoded '/' never matches a Windows
|
|
649
|
+
// `C:\a\b` subpath and the listing comes back empty.
|
|
604
650
|
where.push('(cwd = ? OR cwd LIKE ?)');
|
|
605
|
-
params.push(options.cwdPrefix, options.cwdPrefix + '
|
|
651
|
+
params.push(options.cwdPrefix, options.cwdPrefix + path.sep + '%');
|
|
606
652
|
}
|
|
607
653
|
if (options.project) {
|
|
608
654
|
where.push('LOWER(IFNULL(project, \'\')) LIKE ?');
|
|
609
655
|
params.push(`%${options.project.toLowerCase()}%`);
|
|
610
656
|
}
|
|
657
|
+
// id lookup. SQLite's LIKE is case-insensitive for ASCII, so a lowercased
|
|
658
|
+
// pattern matches mixed-case ids; the `=` exact compare adds COLLATE NOCASE
|
|
659
|
+
// for the same reason. short_id carries its own index (idx_sessions_short_id);
|
|
660
|
+
// id is the PRIMARY KEY.
|
|
661
|
+
if (options.idExact) {
|
|
662
|
+
where.push('(id = ? COLLATE NOCASE OR short_id = ? COLLATE NOCASE)');
|
|
663
|
+
params.push(options.idExact, options.idExact);
|
|
664
|
+
}
|
|
665
|
+
if (options.idPrefix) {
|
|
666
|
+
where.push('(id LIKE ? OR short_id LIKE ?)');
|
|
667
|
+
params.push(`${options.idPrefix}%`, `${options.idPrefix}%`);
|
|
668
|
+
}
|
|
611
669
|
if (typeof options.sinceMs === 'number') {
|
|
612
670
|
// Compare as strings; ISO 8601 timestamps sort lexicographically.
|
|
613
671
|
where.push('timestamp >= ?');
|
|
@@ -739,6 +797,23 @@ export function getSessionById(id) {
|
|
|
739
797
|
const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
|
|
740
798
|
return row ? rowToMeta(row) : null;
|
|
741
799
|
}
|
|
800
|
+
/**
|
|
801
|
+
* Resolve a full-or-partial session id against the index, exact-first then
|
|
802
|
+
* prefix — the DB-backed equivalent of resolveSessionById() that runs over the
|
|
803
|
+
* SQLite table instead of a pre-loaded array. Matches both the full id and the
|
|
804
|
+
* short id. An exact hit short-circuits so a complete id never also drags in its
|
|
805
|
+
* prefix siblings. `scope` narrows by agent / version / project (cwd) so an
|
|
806
|
+
* ambiguous prefix disambiguates against the caller's context.
|
|
807
|
+
*/
|
|
808
|
+
export function findSessionsById(idQuery, scope = {}) {
|
|
809
|
+
const q = idQuery.trim();
|
|
810
|
+
if (!q)
|
|
811
|
+
return [];
|
|
812
|
+
const exact = querySessions({ ...scope, idExact: q });
|
|
813
|
+
if (exact.length > 0)
|
|
814
|
+
return exact;
|
|
815
|
+
return querySessions({ ...scope, idPrefix: q });
|
|
816
|
+
}
|
|
742
817
|
/**
|
|
743
818
|
* Escape a raw user query into a safe FTS5 MATCH expression.
|
|
744
819
|
* Splits on non-word characters, keeps tokens >= 2 chars, and OR-joins
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* subsequent queries are served entirely from the cache.
|
|
8
8
|
*/
|
|
9
9
|
import type { SessionAgentId, SessionMeta } from './types.js';
|
|
10
|
+
import { type ScanStamp } from './db.js';
|
|
10
11
|
/** Options controlling which sessions to discover and how to report progress. */
|
|
11
12
|
export interface DiscoverOptions {
|
|
12
13
|
agent?: SessionAgentId;
|
|
@@ -36,6 +37,27 @@ export interface ScanProgress {
|
|
|
36
37
|
parsed: number;
|
|
37
38
|
total: number;
|
|
38
39
|
}
|
|
40
|
+
/** Lightweight metadata extracted from a Claude JSONL file during incremental scan. */
|
|
41
|
+
interface ClaudeSessionScan {
|
|
42
|
+
timestamp?: string;
|
|
43
|
+
cwd?: string;
|
|
44
|
+
gitBranch?: string;
|
|
45
|
+
version?: string;
|
|
46
|
+
topic?: string;
|
|
47
|
+
messageCount: number;
|
|
48
|
+
tokenCount?: number;
|
|
49
|
+
/** Total USD cost accumulated from per-(model, direction) token usage. */
|
|
50
|
+
costUsd?: number;
|
|
51
|
+
/** Wall-clock duration in ms between the first and last timestamped event. */
|
|
52
|
+
durationMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Value of the JSONL `entrypoint` field on the first event that carries it.
|
|
55
|
+
* 'cli' for real interactive sessions, 'sdk-cli' for team-spawned ones.
|
|
56
|
+
*/
|
|
57
|
+
entrypoint?: string;
|
|
58
|
+
/** Concatenated user message text, ready to hand to FTS5. */
|
|
59
|
+
contentText?: string;
|
|
60
|
+
}
|
|
39
61
|
/**
|
|
40
62
|
* Discover sessions. Scans only files whose (mtime, size) have changed since
|
|
41
63
|
* the last run; everything else is served from the SQLite cache.
|
|
@@ -64,6 +86,7 @@ export declare function resolveSessionById(sessions: SessionMeta[], idQuery: str
|
|
|
64
86
|
* preserving the existing SessionMeta[] contract so sessions.ts is unchanged.
|
|
65
87
|
*/
|
|
66
88
|
export declare function searchContentIndex(sessions: SessionMeta[], query: string): Map<string, SessionMeta>;
|
|
89
|
+
export declare function shouldDeferRecentAppend(prev: ScanStamp, current: ScanStamp, nowMs: number, debounceMs?: number): boolean;
|
|
67
90
|
/**
|
|
68
91
|
* Collect all directories to scan for an agent's sessions. Deduplicates by
|
|
69
92
|
* realpath to avoid double-counting symlinked version homes.
|
|
@@ -77,7 +100,12 @@ export declare function getAgentSessionDirs(agent: string, subdir: string): stri
|
|
|
77
100
|
* recent startedAt.
|
|
78
101
|
*/
|
|
79
102
|
export declare function buildClaudeLabelMap(): Map<string, string | null>;
|
|
103
|
+
/** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
|
|
104
|
+
export declare function parseCodexThreadNameIndex(raw: string): Map<string, string>;
|
|
105
|
+
/** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
|
|
106
|
+
export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessionScan>;
|
|
80
107
|
/** Read up to maxLines non-empty lines from the beginning of a file. */
|
|
81
108
|
export declare function readFirstLines(filePath: string, maxLines: number): Promise<string[]>;
|
|
82
109
|
/** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
|
|
83
110
|
export declare function parseTimeFilter(input: string): number;
|
|
111
|
+
export {};
|