@phnx-labs/agents-cli 1.20.41 → 1.20.43
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 +14 -0
- package/dist/commands/computer-actions.js +1 -1
- package/dist/commands/computer.d.ts +2 -2
- package/dist/commands/computer.js +4 -4
- package/dist/commands/exec.js +18 -6
- package/dist/commands/go.js +6 -1
- package/dist/commands/hosts.js +10 -6
- package/dist/commands/secrets.js +26 -0
- package/dist/commands/sessions.js +30 -12
- package/dist/lib/browser/chrome.d.ts +22 -0
- package/dist/lib/browser/chrome.js +53 -13
- package/dist/lib/browser/service.js +13 -0
- package/dist/lib/computer-rpc.js +3 -3
- package/dist/lib/exec.d.ts +59 -0
- package/dist/lib/exec.js +163 -0
- package/dist/lib/hosts/dispatch.d.ts +5 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/session-index.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +15 -0
- package/dist/lib/hosts/tasks.js +16 -0
- package/dist/lib/menubar/install-menubar.js +2 -2
- package/dist/lib/rotate.d.ts +11 -6
- package/dist/lib/rotate.js +25 -11
- package/dist/lib/secrets/bundles.js +5 -3
- package/dist/lib/secrets/remote.js +14 -0
- package/dist/lib/secrets/sync.js +13 -0
- package/dist/lib/session/active.d.ts +49 -3
- package/dist/lib/session/active.js +139 -10
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +129 -50
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +14 -3
- package/dist/lib/session/remote.d.ts +4 -6
- package/dist/lib/session/remote.js +5 -12
- package/dist/lib/session/run-names.d.ts +32 -0
- package/dist/lib/session/run-names.js +63 -0
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/session/viewing-in.d.ts +54 -0
- package/dist/lib/session/viewing-in.js +155 -0
- package/dist/lib/ssh-tunnel.d.ts +1 -1
- package/dist/lib/ssh-tunnel.js +3 -3
- package/dist/lib/tmux/session.d.ts +46 -0
- package/dist/lib/tmux/session.js +84 -2
- package/dist/lib/usage.d.ts +5 -3
- package/dist/lib/usage.js +5 -3
- package/package.json +1 -1
|
@@ -26,6 +26,7 @@ import { AgentManager } from '../teams/agents.js';
|
|
|
26
26
|
import { getTerminalsDir } from '../state.js';
|
|
27
27
|
import { readPidSessionEntry, prunePidSessionRegistry } from './pid-registry.js';
|
|
28
28
|
import { buildClaudeLabelMap } from './discover.js';
|
|
29
|
+
import { buildRunNameMap } from './run-names.js';
|
|
29
30
|
import { latestSessionFileForCwd } from './db.js';
|
|
30
31
|
import { extractSessionTopic } from './prompt.js';
|
|
31
32
|
import { readSessionTail } from './tail.js';
|
|
@@ -120,11 +121,24 @@ function claudeProjectDirName(cwd) {
|
|
|
120
121
|
* fall back to the most-recent-mtime .jsonl in the project's folder.
|
|
121
122
|
*/
|
|
122
123
|
function findClaudeSessionFile(cwd, sessionId) {
|
|
123
|
-
|
|
124
|
+
return pickSessionFile(path.join(HOME, '.claude', 'projects', claudeProjectDirName(cwd)), sessionId);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Pick a Claude transcript file within a project dir.
|
|
128
|
+
*
|
|
129
|
+
* With a CONCRETE session id: return that id's `<id>.jsonl` or undefined — NEVER a
|
|
130
|
+
* sibling's. Falling back to the newest file here is the bug that made N distinct
|
|
131
|
+
* co-located sessions (e.g. several editor tabs in one cwd, or two worktree siblings)
|
|
132
|
+
* all collapse onto ONE file and render identical preview + topic (they look like
|
|
133
|
+
* duplicate cards). The mtime fallback is only sound when NO id is known.
|
|
134
|
+
*
|
|
135
|
+
* With NO id: return the newest `.jsonl` by mtime (the legitimate single-session
|
|
136
|
+
* heuristic for a directly-launched agent with no registry entry).
|
|
137
|
+
*/
|
|
138
|
+
export function pickSessionFile(projectDir, sessionId) {
|
|
124
139
|
if (sessionId) {
|
|
125
140
|
const specific = path.join(projectDir, `${sessionId}.jsonl`);
|
|
126
|
-
|
|
127
|
-
return specific;
|
|
141
|
+
return fs.existsSync(specific) ? specific : undefined;
|
|
128
142
|
}
|
|
129
143
|
let files;
|
|
130
144
|
try {
|
|
@@ -146,8 +160,12 @@ function findClaudeSessionFile(cwd, sessionId) {
|
|
|
146
160
|
return best?.path;
|
|
147
161
|
}
|
|
148
162
|
function classifyActivity(sessionFile) {
|
|
163
|
+
// No resolvable transcript is NOT evidence of activity — default to idle. (Before
|
|
164
|
+
// the no-borrow fix in pickSessionFile this rarely fired because every terminal
|
|
165
|
+
// borrowed the newest file; now a session with an unresolved id lands here, and
|
|
166
|
+
// "running" would wrongly light it up.)
|
|
149
167
|
if (!sessionFile)
|
|
150
|
-
return '
|
|
168
|
+
return 'idle';
|
|
151
169
|
try {
|
|
152
170
|
const mtimeMs = fs.statSync(sessionFile).mtimeMs;
|
|
153
171
|
return Date.now() - mtimeMs < ACTIVE_MTIME_WINDOW_MS ? 'running' : 'idle';
|
|
@@ -325,10 +343,25 @@ export async function listTerminalsActive() {
|
|
|
325
343
|
procByPid.set(r.pid, r);
|
|
326
344
|
// Build label map from Claude's sessions/*.json for /rename support
|
|
327
345
|
const labelMap = buildClaudeLabelMap();
|
|
346
|
+
// Run-name handles (`agents run --name`) keyed by session id, for the same
|
|
347
|
+
// sessionId → handle resolution as labels.
|
|
348
|
+
const runNameMap = buildRunNameMap();
|
|
328
349
|
return entries.map((t) => {
|
|
329
|
-
|
|
350
|
+
// The id cached in live-terminals.json goes stale when Claude rotates its
|
|
351
|
+
// transcript uuid on resume/compact, so it often no longer matches any
|
|
352
|
+
// <id>.jsonl. When the pid registry knows this pid's current id, prefer it —
|
|
353
|
+
// the same source the headless path uses. NOTE: live-terminals.json stores the
|
|
354
|
+
// SHELL pid, while the by-pid registry is keyed by the AGENT pid, so for
|
|
355
|
+
// editor-launched terminals this lookup returns undefined today and we fall
|
|
356
|
+
// back to the stale cached id — the duplicate-card fix comes from
|
|
357
|
+
// pickSessionFile no longer borrowing a sibling, not from this lookup. Kept as
|
|
358
|
+
// a forward-looking hook for the cases where the pid does line up.
|
|
359
|
+
const resolvedId = readPidSessionEntry(t.pid)?.sessionId ?? t.sessionId;
|
|
360
|
+
const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId);
|
|
330
361
|
// Prefer label from live terminal, fall back to Claude's session label
|
|
331
362
|
const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
|
|
363
|
+
// Durable run name from `agents run --name`, resolved by the run's session id.
|
|
364
|
+
const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
|
|
332
365
|
// Extract topic from session file (first meaningful user message)
|
|
333
366
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
334
367
|
const state = computeLiveState(t.kind, sessionFile, t.cwd ?? undefined, isPidAlive(t.pid));
|
|
@@ -341,6 +374,7 @@ export async function listTerminalsActive() {
|
|
|
341
374
|
sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
|
|
342
375
|
cwd: t.cwd ?? undefined,
|
|
343
376
|
label,
|
|
377
|
+
name,
|
|
344
378
|
topic,
|
|
345
379
|
sessionFile,
|
|
346
380
|
startedAtMs: t.startedAtMs,
|
|
@@ -539,6 +573,21 @@ function detectHost(pid, procByPid) {
|
|
|
539
573
|
}
|
|
540
574
|
return undefined;
|
|
541
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* Resolve the host app for a single pid by walking its process ancestry with the
|
|
578
|
+
* same HOST_MATCHERS logic `detectHost` uses. Reads the whole process table per
|
|
579
|
+
* call, so it's for the low-cardinality renderer path (one tmux client per
|
|
580
|
+
* session), not a hot loop. Returns undefined when nothing above the pid is a
|
|
581
|
+
* recognised UI. Exported for the "viewing in <app>" resolver.
|
|
582
|
+
*/
|
|
583
|
+
export async function hostFromPid(pid) {
|
|
584
|
+
if (!pid || pid < 1)
|
|
585
|
+
return undefined;
|
|
586
|
+
const procByPid = new Map();
|
|
587
|
+
for (const r of await readProcessTable())
|
|
588
|
+
procByPid.set(r.pid, r);
|
|
589
|
+
return detectHost(pid, procByPid);
|
|
590
|
+
}
|
|
542
591
|
/** IDE / terminal / multiplexer hosts all count as UI-hosted. Absence = truly headless. */
|
|
543
592
|
const UI_HOSTS = new Set([
|
|
544
593
|
'code', 'cursor', 'codium', 'windsurf',
|
|
@@ -703,17 +752,97 @@ export async function listUnattributedActive(attributed) {
|
|
|
703
752
|
return out;
|
|
704
753
|
}
|
|
705
754
|
/**
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
755
|
+
* Agents hosted in the shared-socket tmux server — the authoritative source for
|
|
756
|
+
* tmux-wrapped interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
|
|
757
|
+
* every pane on the shared socket and keeps those whose session meta was stamped
|
|
758
|
+
* with `labels.agent` + `labels.sessionId` by the spawn-wrap. Because tmux (not a
|
|
759
|
+
* per-window `live-terminals.json`) is the source of truth, a tmux-hosted agent is
|
|
760
|
+
* ALWAYS captured with its exact `%pane` even when the extension registry is stale
|
|
761
|
+
* or absent. `source: 'teams'` is skipped — teammates are surfaced by listTeamsActive.
|
|
762
|
+
*/
|
|
763
|
+
export async function listTmuxAgentSessions() {
|
|
764
|
+
const { getDefaultSocketPath } = await import('../tmux/paths.js');
|
|
765
|
+
const { readSessionMeta } = await import('../tmux/session.js');
|
|
766
|
+
const { runTmux } = await import('../tmux/binary.js');
|
|
767
|
+
const socket = getDefaultSocketPath();
|
|
768
|
+
if (!fs.existsSync(socket))
|
|
769
|
+
return [];
|
|
770
|
+
let res;
|
|
771
|
+
try {
|
|
772
|
+
res = await runTmux({
|
|
773
|
+
socket,
|
|
774
|
+
args: ['list-panes', '-a', '-F', '#{pane_id}\t#{session_name}\t#{pane_pid}\t#{pane_current_path}'],
|
|
775
|
+
throwOnError: false,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return [];
|
|
780
|
+
}
|
|
781
|
+
if (res.code !== 0)
|
|
782
|
+
return [];
|
|
783
|
+
const out = [];
|
|
784
|
+
const seen = new Set();
|
|
785
|
+
for (const line of res.stdout.split('\n')) {
|
|
786
|
+
if (!line.trim())
|
|
787
|
+
continue;
|
|
788
|
+
const [pane, sessName, pidRaw, curPath] = line.split('\t');
|
|
789
|
+
if (!pane || !sessName)
|
|
790
|
+
continue;
|
|
791
|
+
const meta = readSessionMeta(sessName);
|
|
792
|
+
const agent = meta?.labels?.agent;
|
|
793
|
+
const sessionId = meta?.labels?.sessionId;
|
|
794
|
+
if (!agent || !sessionId)
|
|
795
|
+
continue; // only our stamped agent sessions
|
|
796
|
+
if (meta?.source === 'teams')
|
|
797
|
+
continue; // teammates come from listTeamsActive
|
|
798
|
+
if (seen.has(sessionId))
|
|
799
|
+
continue; // first pane per session wins
|
|
800
|
+
seen.add(sessionId);
|
|
801
|
+
const pid = parseInt(pidRaw, 10) || undefined;
|
|
802
|
+
const cwd = meta?.cwd ?? (curPath || undefined);
|
|
803
|
+
const sessionFile = findSessionFileForKind(agent, cwd, sessionId);
|
|
804
|
+
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
805
|
+
const state = computeLiveState(agent, sessionFile, cwd, pid ? isPidAlive(pid) : true);
|
|
806
|
+
// Provenance is known exactly here (the pane IS a tmux pane) — set it so
|
|
807
|
+
// enrichProvenance skips it and the locator/reply rails resolve off the pane.
|
|
808
|
+
const provenance = {
|
|
809
|
+
host: os.hostname(),
|
|
810
|
+
transport: 'local',
|
|
811
|
+
mux: { kind: 'tmux', socket, pane },
|
|
812
|
+
reply: { rail: 'tmux', target: pane, socket },
|
|
813
|
+
};
|
|
814
|
+
out.push(applyState({
|
|
815
|
+
context: 'terminal',
|
|
816
|
+
kind: agent,
|
|
817
|
+
host: 'tmux',
|
|
818
|
+
pid,
|
|
819
|
+
sessionId,
|
|
820
|
+
cwd,
|
|
821
|
+
topic,
|
|
822
|
+
sessionFile,
|
|
823
|
+
provenance,
|
|
824
|
+
}, state, sessionFile));
|
|
825
|
+
}
|
|
826
|
+
return out;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Union of all sources. Teams and terminals spawn actual CLI processes that
|
|
830
|
+
* also show up in `ps`, so headless attribution runs last with the already-
|
|
831
|
+
* attributed PIDs removed. The tmux source goes FIRST into the dedupe so a
|
|
832
|
+
* tmux-hosted agent's row (which carries the exact `%pane`) wins over a staler
|
|
833
|
+
* terminal/headless row for the same session id.
|
|
709
834
|
*/
|
|
710
835
|
export async function getActiveSessions(opts = {}) {
|
|
711
|
-
const [teams, terminals, cloud] = await Promise.all([
|
|
836
|
+
const [tmuxAgents, teams, terminals, cloud] = await Promise.all([
|
|
837
|
+
listTmuxAgentSessions().catch(() => []),
|
|
712
838
|
listTeamsActive().catch(() => []),
|
|
713
839
|
listTerminalsActive().catch(() => []),
|
|
714
840
|
Promise.resolve(listCloudActive()),
|
|
715
841
|
]);
|
|
716
842
|
const knownPids = new Set();
|
|
843
|
+
for (const s of tmuxAgents)
|
|
844
|
+
if (s.pid)
|
|
845
|
+
knownPids.add(s.pid);
|
|
717
846
|
for (const s of teams)
|
|
718
847
|
if (s.pid)
|
|
719
848
|
knownPids.add(s.pid);
|
|
@@ -721,7 +850,7 @@ export async function getActiveSessions(opts = {}) {
|
|
|
721
850
|
if (s.pid)
|
|
722
851
|
knownPids.add(s.pid);
|
|
723
852
|
const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
|
|
724
|
-
const merged = dedupeBySession([...teams, ...terminals, ...cloud, ...unattributed]);
|
|
853
|
+
const merged = dedupeBySession([...tmuxAgents, ...teams, ...terminals, ...cloud, ...unattributed]);
|
|
725
854
|
await enrichProvenance(merged);
|
|
726
855
|
return merged;
|
|
727
856
|
}
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface SessionRow {
|
|
|
22
22
|
git_branch: string | null;
|
|
23
23
|
topic: string | null;
|
|
24
24
|
label: string | null;
|
|
25
|
+
name: string | null;
|
|
25
26
|
message_count: number | null;
|
|
26
27
|
token_count: number | null;
|
|
27
28
|
cost_usd: number | null;
|
|
@@ -128,6 +129,16 @@ export declare function upsertSessionsBatch(entries: Array<{
|
|
|
128
129
|
* Leaves FTS5 content/topic/project untouched — cheap to call every run.
|
|
129
130
|
*/
|
|
130
131
|
export declare function syncLabels(labelMap: Map<string, string | null>): number;
|
|
132
|
+
/**
|
|
133
|
+
* Sync `agents run --name` handles for a set of sessions, keyed by session id.
|
|
134
|
+
* The name's source of truth lives outside the transcript (host task sidecars,
|
|
135
|
+
* run-name sidecars written at launch), so — like {@link syncLabels} — it is
|
|
136
|
+
* re-applied by id every scan rather than parsed per-file. Updates only
|
|
137
|
+
* `sessions.name` (names resolve via a direct column tier in ftsSearch, not
|
|
138
|
+
* FTS, so there's no session_text column to touch). Only writes when the value
|
|
139
|
+
* differs; cheap to call every run. Returns the number of rows updated.
|
|
140
|
+
*/
|
|
141
|
+
export declare function syncNames(nameMap: Map<string, string | null>): number;
|
|
131
142
|
/**
|
|
132
143
|
* Sync topics (session titles) for a set of sessions, keyed by id. For agents
|
|
133
144
|
* whose human-readable title lives in a side index that updates independently
|
package/dist/lib/session/db.js
CHANGED
|
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
|
|
|
13
13
|
const SESSIONS_DIR = getSessionsDir();
|
|
14
14
|
const DB_PATH = getSessionsDbPath();
|
|
15
15
|
/** Current schema version; bumped when migrations are added. */
|
|
16
|
-
const SCHEMA_VERSION =
|
|
16
|
+
const SCHEMA_VERSION = 9;
|
|
17
17
|
/**
|
|
18
18
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
19
19
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
52
52
|
git_branch TEXT,
|
|
53
53
|
topic TEXT,
|
|
54
54
|
label TEXT,
|
|
55
|
+
name TEXT,
|
|
55
56
|
message_count INTEGER,
|
|
56
57
|
token_count INTEGER,
|
|
57
58
|
cost_usd REAL,
|
|
@@ -188,6 +189,15 @@ function migrateSchema(db, fromVersion) {
|
|
|
188
189
|
db.exec(`UPDATE sessions SET last_activity = timestamp WHERE last_activity IS NULL`);
|
|
189
190
|
db.exec(`DELETE FROM scan_ledger;`);
|
|
190
191
|
}
|
|
192
|
+
if (fromVersion < 9) {
|
|
193
|
+
// v8 → v9: `agents run --name <slug>` gives a run a durable launch handle,
|
|
194
|
+
// resolvable via `agents sessions <name>`. Additive column; NO rescan — the
|
|
195
|
+
// name is set at run time (host sidecar / run-name sidecar), not parsed from
|
|
196
|
+
// transcripts, so existing rows stay valid with a NULL name.
|
|
197
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
198
|
+
if (!cols.some(c => c.name === 'name'))
|
|
199
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN name TEXT`);
|
|
200
|
+
}
|
|
191
201
|
}
|
|
192
202
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
193
203
|
export function getDB() {
|
|
@@ -401,13 +411,13 @@ export function recordScans(entries) {
|
|
|
401
411
|
const upsertSessionStmt = (db) => db.prepare(`
|
|
402
412
|
INSERT INTO sessions (
|
|
403
413
|
id, short_id, agent, version, account, timestamp, last_activity,
|
|
404
|
-
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
414
|
+
project, cwd, git_branch, topic, label, name, message_count, token_count,
|
|
405
415
|
cost_usd, duration_ms,
|
|
406
416
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
407
417
|
pr_url, pr_number, worktree_slug, ticket_id
|
|
408
418
|
) VALUES (
|
|
409
419
|
@id, @short_id, @agent, @version, @account, @timestamp, @last_activity,
|
|
410
|
-
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
420
|
+
@project, @cwd, @git_branch, @topic, @label, @name, @message_count, @token_count,
|
|
411
421
|
@cost_usd, @duration_ms,
|
|
412
422
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
413
423
|
@pr_url, @pr_number, @worktree_slug, @ticket_id
|
|
@@ -471,6 +481,7 @@ export function upsertSession(meta, content, scan) {
|
|
|
471
481
|
git_branch: meta.gitBranch ?? null,
|
|
472
482
|
topic: meta.topic ?? null,
|
|
473
483
|
label: meta.label ?? null,
|
|
484
|
+
name: meta.name ?? null,
|
|
474
485
|
message_count: meta.messageCount ?? null,
|
|
475
486
|
token_count: meta.tokenCount ?? null,
|
|
476
487
|
cost_usd: meta.costUsd ?? null,
|
|
@@ -538,37 +549,52 @@ export function upsertSessionsBatch(entries) {
|
|
|
538
549
|
for (const { meta, content, scan } of items) {
|
|
539
550
|
if (alreadyIndexed.has(meta.id))
|
|
540
551
|
continue;
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
552
|
+
// Per-row guard: one malformed session (e.g. a required field that resolves to
|
|
553
|
+
// NULL) must not abort the whole batch and take down the entire `agents sessions`
|
|
554
|
+
// listing. A constraint error uses SQLite's ABORT resolution — it reverts only the
|
|
555
|
+
// failing statement, not the transaction — and the db.transaction wrapper only rolls
|
|
556
|
+
// back when the error escapes `fn`, so catching + skipping here leaves the txn valid
|
|
557
|
+
// and committable. We deliberately do NOT stamp the ledger for a skipped row, so the
|
|
558
|
+
// next scan re-tries it (self-healing once the underlying parser is fixed).
|
|
559
|
+
try {
|
|
560
|
+
upsert.run({
|
|
561
|
+
id: meta.id,
|
|
562
|
+
short_id: meta.shortId,
|
|
563
|
+
agent: meta.agent,
|
|
564
|
+
version: meta.version ?? null,
|
|
565
|
+
account: meta.account ?? null,
|
|
566
|
+
timestamp: meta.timestamp,
|
|
567
|
+
last_activity: resolveLastActivity(meta, scan),
|
|
568
|
+
project: meta.project ?? null,
|
|
569
|
+
cwd: meta.cwd ?? null,
|
|
570
|
+
git_branch: meta.gitBranch ?? null,
|
|
571
|
+
topic: meta.topic ?? null,
|
|
572
|
+
label: meta.label ?? null,
|
|
573
|
+
name: meta.name ?? null,
|
|
574
|
+
message_count: meta.messageCount ?? null,
|
|
575
|
+
token_count: meta.tokenCount ?? null,
|
|
576
|
+
cost_usd: meta.costUsd ?? null,
|
|
577
|
+
duration_ms: meta.durationMs ?? null,
|
|
578
|
+
file_path: meta.filePath,
|
|
579
|
+
file_mtime_ms: scan?.fileMtimeMs ?? null,
|
|
580
|
+
file_size: scan?.fileSize ?? null,
|
|
581
|
+
scanned_at: now,
|
|
582
|
+
is_team_origin: meta.isTeamOrigin ? 1 : 0,
|
|
583
|
+
pr_url: meta.prUrl ?? null,
|
|
584
|
+
pr_number: meta.prNumber ?? null,
|
|
585
|
+
worktree_slug: meta.worktreeSlug ?? null,
|
|
586
|
+
ticket_id: meta.ticketId ?? null,
|
|
587
|
+
});
|
|
588
|
+
delText.run(meta.id);
|
|
589
|
+
insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
|
|
590
|
+
if (scan && meta.filePath) {
|
|
591
|
+
ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
if (process.stderr.isTTY) {
|
|
596
|
+
console.error(`Warning: skipped unindexable session ${meta.id}: ${err.message}`);
|
|
597
|
+
}
|
|
572
598
|
}
|
|
573
599
|
}
|
|
574
600
|
});
|
|
@@ -612,6 +638,45 @@ export function syncLabels(labelMap) {
|
|
|
612
638
|
txn(updates);
|
|
613
639
|
return updates.length;
|
|
614
640
|
}
|
|
641
|
+
/**
|
|
642
|
+
* Sync `agents run --name` handles for a set of sessions, keyed by session id.
|
|
643
|
+
* The name's source of truth lives outside the transcript (host task sidecars,
|
|
644
|
+
* run-name sidecars written at launch), so — like {@link syncLabels} — it is
|
|
645
|
+
* re-applied by id every scan rather than parsed per-file. Updates only
|
|
646
|
+
* `sessions.name` (names resolve via a direct column tier in ftsSearch, not
|
|
647
|
+
* FTS, so there's no session_text column to touch). Only writes when the value
|
|
648
|
+
* differs; cheap to call every run. Returns the number of rows updated.
|
|
649
|
+
*/
|
|
650
|
+
export function syncNames(nameMap) {
|
|
651
|
+
if (nameMap.size === 0)
|
|
652
|
+
return 0;
|
|
653
|
+
const db = getDB();
|
|
654
|
+
const ids = [...nameMap.keys()];
|
|
655
|
+
const CHUNK = 500;
|
|
656
|
+
const updates = [];
|
|
657
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
658
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
659
|
+
const placeholders = chunk.map(() => '?').join(',');
|
|
660
|
+
const rows = db
|
|
661
|
+
.prepare(`SELECT id, name FROM sessions WHERE id IN (${placeholders})`)
|
|
662
|
+
.all(...chunk);
|
|
663
|
+
for (const row of rows) {
|
|
664
|
+
const live = nameMap.get(row.id) ?? null;
|
|
665
|
+
if ((live ?? '') !== (row.name ?? '')) {
|
|
666
|
+
updates.push({ id: row.id, name: live });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (updates.length === 0)
|
|
671
|
+
return 0;
|
|
672
|
+
const upd = db.prepare(`UPDATE sessions SET name = ? WHERE id = ?`);
|
|
673
|
+
const txn = db.transaction((items) => {
|
|
674
|
+
for (const { id, name } of items)
|
|
675
|
+
upd.run(name, id);
|
|
676
|
+
});
|
|
677
|
+
txn(updates);
|
|
678
|
+
return updates.length;
|
|
679
|
+
}
|
|
615
680
|
/**
|
|
616
681
|
* Sync topics (session titles) for a set of sessions, keyed by id. For agents
|
|
617
682
|
* whose human-readable title lives in a side index that updates independently
|
|
@@ -674,6 +739,7 @@ function rowToMeta(row) {
|
|
|
674
739
|
account: row.account ?? undefined,
|
|
675
740
|
topic: row.topic ?? undefined,
|
|
676
741
|
label: row.label ?? undefined,
|
|
742
|
+
name: row.name ?? undefined,
|
|
677
743
|
isTeamOrigin: row.is_team_origin === 1,
|
|
678
744
|
prUrl: row.pr_url ?? undefined,
|
|
679
745
|
prNumber: row.pr_number ?? undefined,
|
|
@@ -942,26 +1008,39 @@ export function ftsSearch(input, limit = 200) {
|
|
|
942
1008
|
const lower = trimmed.toLowerCase();
|
|
943
1009
|
const seen = new Set();
|
|
944
1010
|
const hits = [];
|
|
945
|
-
// Tier 1-3:
|
|
1011
|
+
// Tier 1-3: handle-based matches, ordered by exactness. A session's handle is
|
|
1012
|
+
// its /rename `label` OR its `agents run --name` handle — both are user-chosen
|
|
1013
|
+
// aliases and rank identically, so typing either the renamed title or the run
|
|
1014
|
+
// name resolves the session ahead of any FTS content hit.
|
|
946
1015
|
const labelRows = db.prepare(`
|
|
947
|
-
SELECT id, label FROM sessions
|
|
948
|
-
WHERE label IS NOT NULL AND LOWER(label) LIKE ?
|
|
949
|
-
|
|
1016
|
+
SELECT id, label, name FROM sessions
|
|
1017
|
+
WHERE (label IS NOT NULL AND LOWER(label) LIKE ?)
|
|
1018
|
+
OR (name IS NOT NULL AND LOWER(name) LIKE ?)
|
|
1019
|
+
`).all(`%${lower}%`, `%${lower}%`);
|
|
950
1020
|
let hasExactLabelMatch = false;
|
|
951
1021
|
for (const row of labelRows) {
|
|
952
|
-
|
|
953
|
-
let score;
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1022
|
+
// Score against whichever handle matches best (exact > prefix > contains).
|
|
1023
|
+
let score = 0;
|
|
1024
|
+
for (const handle of [row.label, row.name]) {
|
|
1025
|
+
if (!handle)
|
|
1026
|
+
continue;
|
|
1027
|
+
const h = handle.toLowerCase();
|
|
1028
|
+
if (!h.includes(lower))
|
|
1029
|
+
continue;
|
|
1030
|
+
if (h === lower) {
|
|
1031
|
+
score = Math.max(score, 1_000_000);
|
|
1032
|
+
hasExactLabelMatch = true;
|
|
1033
|
+
}
|
|
1034
|
+
else if (h.startsWith(lower)) {
|
|
1035
|
+
score = Math.max(score, 900_000);
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
score = Math.max(score, 800_000);
|
|
1039
|
+
}
|
|
963
1040
|
}
|
|
964
|
-
|
|
1041
|
+
if (score === 0)
|
|
1042
|
+
continue;
|
|
1043
|
+
// matchedTerms is empty for handle hits — the picker can render the handle
|
|
965
1044
|
// itself as the highlight, no badge needed.
|
|
966
1045
|
hits.push({ sessionId: row.id, score, matchedTerms: [] });
|
|
967
1046
|
seen.add(row.id);
|
|
@@ -150,6 +150,11 @@ export declare function readCodexMeta(filePath: string, resolveAccount?: () => s
|
|
|
150
150
|
export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessionScan>;
|
|
151
151
|
/** Read up to maxLines non-empty lines from the beginning of a file. */
|
|
152
152
|
export declare function readFirstLines(filePath: string, maxLines: number): Promise<string[]>;
|
|
153
|
+
/** Parse a single Kimi session state.json file to extract session metadata. */
|
|
154
|
+
export declare function readKimiMeta(filePath: string): {
|
|
155
|
+
meta: SessionMeta;
|
|
156
|
+
content: string;
|
|
157
|
+
} | null;
|
|
153
158
|
/** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
|
|
154
159
|
export declare function parseTimeFilter(input: string): number;
|
|
155
160
|
export {};
|
|
@@ -25,7 +25,8 @@ import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from '.
|
|
|
25
25
|
import { costOfUsage } from '../pricing/index.js';
|
|
26
26
|
import { machineId } from './sync/config.js';
|
|
27
27
|
import { mapBounded } from '../concurrency.js';
|
|
28
|
-
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
28
|
+
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
29
|
+
import { buildRunNameMap } from './run-names.js';
|
|
29
30
|
const HOME = os.homedir();
|
|
30
31
|
// Versions can live under either repo: the user repo (current canonical
|
|
31
32
|
// location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
|
|
@@ -63,6 +64,9 @@ export async function discoverSessions(options) {
|
|
|
63
64
|
// reads to behavioral EDR (CrowdStrike Falcon) as a ransomware-style bulk
|
|
64
65
|
// file-enumeration sweep. Same dirs, same results — just not all at once.
|
|
65
66
|
await scanAgentsBounded(agents, agent => dispatchAgentScan(agent, onProgress));
|
|
67
|
+
// Apply `agents run --name` handles onto the freshly-scanned rows by id —
|
|
68
|
+
// the same idempotent, re-applied-every-scan pattern as /rename labels.
|
|
69
|
+
syncNames(buildRunNameMap());
|
|
66
70
|
}
|
|
67
71
|
finally {
|
|
68
72
|
releaseScan(process.pid);
|
|
@@ -2345,7 +2349,7 @@ async function scanKimiIncremental(onProgress) {
|
|
|
2345
2349
|
recordScans(touched);
|
|
2346
2350
|
}
|
|
2347
2351
|
/** Parse a single Kimi session state.json file to extract session metadata. */
|
|
2348
|
-
function readKimiMeta(filePath) {
|
|
2352
|
+
export function readKimiMeta(filePath) {
|
|
2349
2353
|
let state;
|
|
2350
2354
|
try {
|
|
2351
2355
|
state = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
@@ -2362,7 +2366,14 @@ function readKimiMeta(filePath) {
|
|
|
2362
2366
|
const topic = title || lastPrompt || undefined;
|
|
2363
2367
|
const createdAt = typeof state.createdAt === 'string' ? state.createdAt : undefined;
|
|
2364
2368
|
const updatedAt = typeof state.updatedAt === 'string' ? state.updatedAt : undefined;
|
|
2365
|
-
|
|
2369
|
+
// Coerce to never-null, the same way every other parser does (Rush/Hermes/Droid/…):
|
|
2370
|
+
// a real createdAt/updatedAt still wins; otherwise fall back to the state.json mtime.
|
|
2371
|
+
// Kimi was the lone parser that could yield `undefined`, which binds NULL into
|
|
2372
|
+
// `timestamp TEXT NOT NULL` and aborts the whole batch index. mtime also matches how
|
|
2373
|
+
// the listing already ranks Kimi (last_activity resolves to the file mtime).
|
|
2374
|
+
const stat = safeStatSync(filePath);
|
|
2375
|
+
const timestamp = updatedAt || createdAt
|
|
2376
|
+
|| (stat ? stat.mtime.toISOString() : new Date().toISOString());
|
|
2366
2377
|
const shortId = sessionId.replace(/^session_/, '').slice(0, 8);
|
|
2367
2378
|
// Try to infer project from session directory path
|
|
2368
2379
|
// ~/.kimi-code/sessions/<workdir_hash>/session_<uuid>/
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* POSIX single-quote a string for safe interpolation into a remote shell command.
|
|
3
|
+
* Always wraps (unlike the bare-passthrough variant in `ssh-exec.ts`) — the
|
|
4
|
+
* forwarded `agents` argv is embedded verbatim inside `bash -lc '<cmd>'`, so
|
|
5
|
+
* every token is quoted to keep the command boundary unambiguous.
|
|
5
6
|
*/
|
|
6
|
-
export declare const SSH_TARGET_RE: RegExp;
|
|
7
|
-
export declare function assertValidSshTarget(host: string): void;
|
|
8
|
-
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
9
7
|
export declare function shellQuote(s: string): string;
|
|
10
8
|
/**
|
|
11
9
|
* Strip the `--host`/`-H` flag (and its value) from a raw `agents sessions` argv,
|
|
@@ -27,24 +27,17 @@ import { join } from 'path';
|
|
|
27
27
|
import { createHash } from 'crypto';
|
|
28
28
|
import chalk from 'chalk';
|
|
29
29
|
import { getCacheDir } from '../state.js';
|
|
30
|
-
import { SSH_OPTS, controlOpts } from '../ssh-exec.js';
|
|
30
|
+
import { SSH_OPTS, controlOpts, assertValidSshTarget } from '../ssh-exec.js';
|
|
31
31
|
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
32
32
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
33
33
|
import { formatRelativeTime } from './relative-time.js';
|
|
34
34
|
import { terminalWidth } from './width.js';
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* POSIX single-quote a string for safe interpolation into a remote shell command.
|
|
37
|
+
* Always wraps (unlike the bare-passthrough variant in `ssh-exec.ts`) — the
|
|
38
|
+
* forwarded `agents` argv is embedded verbatim inside `bash -lc '<cmd>'`, so
|
|
39
|
+
* every token is quoted to keep the command boundary unambiguous.
|
|
39
40
|
*/
|
|
40
|
-
export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
|
|
41
|
-
export function assertValidSshTarget(host) {
|
|
42
|
-
if (!SSH_TARGET_RE.test(host)) {
|
|
43
|
-
throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host ` +
|
|
44
|
-
`(letters, digits, '.', '_', '-').`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
48
41
|
export function shellQuote(s) {
|
|
49
42
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
50
43
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-name index: the join between a `agents run --name <slug>` handle and the
|
|
3
|
+
* session id of the run it named.
|
|
4
|
+
*
|
|
5
|
+
* `agents run` records `<sessionId>.json` here at launch whenever both a name
|
|
6
|
+
* and a session id are known up front (Claude pre-mints its id — see
|
|
7
|
+
* spawnAgent). The session-discovery pass reads these sidecars and applies the
|
|
8
|
+
* names onto the SQLite index by id (via syncNames), the same idempotent,
|
|
9
|
+
* re-applied-every-scan pattern as Claude `/rename` labels. Names therefore
|
|
10
|
+
* survive transcript rescans without being parsed out of the transcript itself.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the host-task sidecar convention (`~/.agents/.cache/hosts/<id>.json`),
|
|
13
|
+
* one small JSON per run under `~/.agents/.cache/run-names/`.
|
|
14
|
+
*/
|
|
15
|
+
export interface RunNameRecord {
|
|
16
|
+
sessionId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
agent: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
ts: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function runNamesDir(): string;
|
|
23
|
+
/**
|
|
24
|
+
* Record a run's `--name` handle keyed by its session id. Best-effort: a failed
|
|
25
|
+
* write must never break the run itself. No-op without both a name and id.
|
|
26
|
+
*/
|
|
27
|
+
export declare function recordRunName(rec: Omit<RunNameRecord, 'ts'>): void;
|
|
28
|
+
/**
|
|
29
|
+
* Build the sessionId → name map from every run-name sidecar, for syncNames to
|
|
30
|
+
* apply onto the index. Returns an empty map when the dir doesn't exist yet.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildRunNameMap(): Map<string, string | null>;
|