@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/commands/computer-actions.js +1 -1
  3. package/dist/commands/computer.d.ts +2 -2
  4. package/dist/commands/computer.js +4 -4
  5. package/dist/commands/exec.js +18 -6
  6. package/dist/commands/go.js +6 -1
  7. package/dist/commands/hosts.js +10 -6
  8. package/dist/commands/secrets.js +26 -0
  9. package/dist/commands/sessions.js +30 -12
  10. package/dist/lib/browser/chrome.d.ts +22 -0
  11. package/dist/lib/browser/chrome.js +53 -13
  12. package/dist/lib/browser/service.js +13 -0
  13. package/dist/lib/computer-rpc.js +3 -3
  14. package/dist/lib/exec.d.ts +59 -0
  15. package/dist/lib/exec.js +163 -0
  16. package/dist/lib/hosts/dispatch.d.ts +5 -0
  17. package/dist/lib/hosts/dispatch.js +4 -0
  18. package/dist/lib/hosts/session-index.js +1 -0
  19. package/dist/lib/hosts/tasks.d.ts +15 -0
  20. package/dist/lib/hosts/tasks.js +16 -0
  21. package/dist/lib/menubar/install-menubar.js +2 -2
  22. package/dist/lib/rotate.d.ts +11 -6
  23. package/dist/lib/rotate.js +25 -11
  24. package/dist/lib/secrets/bundles.js +5 -3
  25. package/dist/lib/secrets/remote.js +14 -0
  26. package/dist/lib/secrets/sync.js +13 -0
  27. package/dist/lib/session/active.d.ts +49 -3
  28. package/dist/lib/session/active.js +139 -10
  29. package/dist/lib/session/db.d.ts +11 -0
  30. package/dist/lib/session/db.js +129 -50
  31. package/dist/lib/session/discover.d.ts +5 -0
  32. package/dist/lib/session/discover.js +14 -3
  33. package/dist/lib/session/remote.d.ts +4 -6
  34. package/dist/lib/session/remote.js +5 -12
  35. package/dist/lib/session/run-names.d.ts +32 -0
  36. package/dist/lib/session/run-names.js +63 -0
  37. package/dist/lib/session/types.d.ts +8 -0
  38. package/dist/lib/session/viewing-in.d.ts +54 -0
  39. package/dist/lib/session/viewing-in.js +155 -0
  40. package/dist/lib/ssh-tunnel.d.ts +1 -1
  41. package/dist/lib/ssh-tunnel.js +3 -3
  42. package/dist/lib/tmux/session.d.ts +46 -0
  43. package/dist/lib/tmux/session.js +84 -2
  44. package/dist/lib/usage.d.ts +5 -3
  45. package/dist/lib/usage.js +5 -3
  46. 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
- const projectDir = path.join(HOME, '.claude', 'projects', claudeProjectDirName(cwd));
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
- if (fs.existsSync(specific))
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 'running';
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
- const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, t.sessionId);
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
- * Union of all four sources. Teams and terminals spawn actual CLI processes
707
- * that also show up in `ps`, so headless attribution runs last with the
708
- * already-attributed PIDs removed.
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
  }
@@ -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
@@ -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 = 8;
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
- upsert.run({
542
- id: meta.id,
543
- short_id: meta.shortId,
544
- agent: meta.agent,
545
- version: meta.version ?? null,
546
- account: meta.account ?? null,
547
- timestamp: meta.timestamp,
548
- last_activity: resolveLastActivity(meta, scan),
549
- project: meta.project ?? null,
550
- cwd: meta.cwd ?? null,
551
- git_branch: meta.gitBranch ?? null,
552
- topic: meta.topic ?? null,
553
- label: meta.label ?? null,
554
- message_count: meta.messageCount ?? null,
555
- token_count: meta.tokenCount ?? null,
556
- cost_usd: meta.costUsd ?? null,
557
- duration_ms: meta.durationMs ?? null,
558
- file_path: meta.filePath,
559
- file_mtime_ms: scan?.fileMtimeMs ?? null,
560
- file_size: scan?.fileSize ?? null,
561
- scanned_at: now,
562
- is_team_origin: meta.isTeamOrigin ? 1 : 0,
563
- pr_url: meta.prUrl ?? null,
564
- pr_number: meta.prNumber ?? null,
565
- worktree_slug: meta.worktreeSlug ?? null,
566
- ticket_id: meta.ticketId ?? null,
567
- });
568
- delText.run(meta.id);
569
- insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
570
- if (scan && meta.filePath) {
571
- ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now);
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: label-based matches, ordered by exactness.
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
- `).all(`%${lower}%`);
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
- const labelLower = row.label.toLowerCase();
953
- let score;
954
- if (labelLower === lower) {
955
- score = 1_000_000;
956
- hasExactLabelMatch = true;
957
- }
958
- else if (labelLower.startsWith(lower)) {
959
- score = 900_000;
960
- }
961
- else {
962
- score = 800_000;
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
- // matchedTerms is empty for label hits — the picker can render the label
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
- const timestamp = updatedAt || createdAt;
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
- * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
3
- * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
4
- * can never be smuggled in as an ssh argv flag.
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
- * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
37
- * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
38
- * can never be smuggled in as an ssh argv flag.
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>;