@phnx-labs/agents-cli 1.20.26 → 1.20.28

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 (95) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +182 -30
  4. package/dist/commands/exec.d.ts +1 -1
  5. package/dist/commands/exec.js +177 -6
  6. package/dist/commands/hosts.d.ts +11 -0
  7. package/dist/commands/hosts.js +229 -0
  8. package/dist/commands/repo.d.ts +29 -0
  9. package/dist/commands/repo.js +174 -38
  10. package/dist/commands/secrets.d.ts +2 -7
  11. package/dist/commands/secrets.js +15 -23
  12. package/dist/commands/sessions.d.ts +2 -0
  13. package/dist/commands/sessions.js +27 -25
  14. package/dist/commands/sync.d.ts +2 -0
  15. package/dist/commands/sync.js +22 -5
  16. package/dist/commands/view.js +27 -11
  17. package/dist/index.js +4 -13
  18. package/dist/lib/agent-spec.d.ts +36 -0
  19. package/dist/lib/agent-spec.js +157 -0
  20. package/dist/lib/agents.d.ts +1 -0
  21. package/dist/lib/agents.js +45 -4
  22. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  23. package/dist/lib/browser/drivers/ssh.js +113 -24
  24. package/dist/lib/browser/profiles.js +28 -1
  25. package/dist/lib/browser/runtime-state.js +28 -8
  26. package/dist/lib/browser/types.d.ts +10 -1
  27. package/dist/lib/cli-resources.js +10 -1
  28. package/dist/lib/daemon.js +32 -0
  29. package/dist/lib/doctor-diff.d.ts +19 -0
  30. package/dist/lib/doctor-diff.js +107 -15
  31. package/dist/lib/exec.d.ts +27 -0
  32. package/dist/lib/exec.js +62 -19
  33. package/dist/lib/heal.d.ts +107 -0
  34. package/dist/lib/heal.js +279 -0
  35. package/dist/lib/hooks.d.ts +17 -0
  36. package/dist/lib/hooks.js +127 -3
  37. package/dist/lib/hosts/dispatch.d.ts +26 -0
  38. package/dist/lib/hosts/dispatch.js +71 -0
  39. package/dist/lib/hosts/progress.d.ts +21 -0
  40. package/dist/lib/hosts/progress.js +49 -0
  41. package/dist/lib/hosts/providers/local.d.ts +17 -0
  42. package/dist/lib/hosts/providers/local.js +81 -0
  43. package/dist/lib/hosts/ready.d.ts +37 -0
  44. package/dist/lib/hosts/ready.js +88 -0
  45. package/dist/lib/hosts/registry.d.ts +22 -0
  46. package/dist/lib/hosts/registry.js +65 -0
  47. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  48. package/dist/lib/hosts/ssh-config.js +157 -0
  49. package/dist/lib/hosts/tasks.d.ts +32 -0
  50. package/dist/lib/hosts/tasks.js +58 -0
  51. package/dist/lib/hosts/types.d.ts +51 -0
  52. package/dist/lib/hosts/types.js +21 -0
  53. package/dist/lib/loop.d.ts +9 -0
  54. package/dist/lib/loop.js +13 -1
  55. package/dist/lib/mcp.js +12 -3
  56. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  57. package/dist/lib/migrate.js +9 -5
  58. package/dist/lib/platform/exec.d.ts +10 -0
  59. package/dist/lib/platform/exec.js +17 -0
  60. package/dist/lib/platform/index.d.ts +1 -0
  61. package/dist/lib/platform/index.js +1 -0
  62. package/dist/lib/platform/links.d.ts +15 -0
  63. package/dist/lib/platform/links.js +42 -0
  64. package/dist/lib/platform/paths.d.ts +18 -0
  65. package/dist/lib/platform/paths.js +22 -0
  66. package/dist/lib/platform/posixpath.d.ts +28 -0
  67. package/dist/lib/platform/posixpath.js +153 -0
  68. package/dist/lib/plugin-marketplace.d.ts +18 -0
  69. package/dist/lib/plugin-marketplace.js +67 -1
  70. package/dist/lib/plugins.d.ts +33 -1
  71. package/dist/lib/plugins.js +56 -11
  72. package/dist/lib/project-launch.js +6 -3
  73. package/dist/lib/sandbox.js +5 -2
  74. package/dist/lib/self-update.js +7 -2
  75. package/dist/lib/session/db.d.ts +23 -0
  76. package/dist/lib/session/db.js +76 -1
  77. package/dist/lib/session/discover.d.ts +26 -0
  78. package/dist/lib/session/discover.js +75 -4
  79. package/dist/lib/session/relative-time.d.ts +7 -0
  80. package/dist/lib/session/relative-time.js +28 -0
  81. package/dist/lib/session/remote.d.ts +61 -0
  82. package/dist/lib/session/remote.js +221 -0
  83. package/dist/lib/ssh-exec.d.ts +45 -0
  84. package/dist/lib/ssh-exec.js +61 -0
  85. package/dist/lib/staleness/detectors/commands.js +7 -6
  86. package/dist/lib/staleness/writers/commands.js +7 -12
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/startup/dev-build.d.ts +22 -0
  90. package/dist/lib/startup/dev-build.js +41 -0
  91. package/dist/lib/types.d.ts +28 -0
  92. package/dist/lib/versions.d.ts +9 -3
  93. package/dist/lib/versions.js +43 -7
  94. package/package.json +3 -3
  95. package/scripts/postinstall.js +62 -0
@@ -11,14 +11,14 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { execFileSync } from 'child_process';
14
- import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir } from './state.js';
14
+ import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir, getSystemPluginsDir } from './state.js';
15
15
  import { IS_WINDOWS, isWindowsAbsolutePath, homeDir } from './platform/index.js';
16
16
  import { assertSafeGitTransport } from './git.js';
17
17
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
18
18
  import { AGENTS, agentConfigDirName } from './agents.js';
19
19
  import { capableAgents, isCapable } from './capabilities.js';
20
20
  import { shouldInstallCommandAsSkill, installCommandSkillToVersion } from './command-skills.js';
21
- import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, } from './plugin-marketplace.js';
21
+ import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
22
22
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
23
23
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
24
24
  const USER_CONFIG_FILE = '.user-config.json';
@@ -186,11 +186,20 @@ export function assertPluginTargetContained(targetRoot, pluginsDir) {
186
186
  }
187
187
  }
188
188
  /**
189
- * Get a specific plugin by name.
189
+ * Get a specific plugin by name. On a cross-marketplace name collision the
190
+ * highest-precedence scope wins (project > extra > user > system) — the same
191
+ * resolution the sync writer's Map(last-wins) dedupe and collectPluginScopes()
192
+ * use. discoverPlugins() yields low→high precedence order, so the LAST match is
193
+ * the winner; returning the first match would resolve to the lowest scope (e.g.
194
+ * a system plugin over the user's same-named one), which is exactly backwards.
190
195
  */
191
196
  export function getPlugin(name) {
192
197
  const plugins = discoverPlugins();
193
- return plugins.find(p => p.name === name) || null;
198
+ for (let i = plugins.length - 1; i >= 0; i--) {
199
+ if (plugins[i].name === name)
200
+ return plugins[i];
201
+ }
202
+ return null;
194
203
  }
195
204
  /**
196
205
  * Check if an agent supports a specific plugin.
@@ -382,13 +391,18 @@ export function checkPluginDependencies(manifest) {
382
391
  /**
383
392
  * Reconstruct a MarketplaceSpec from a marketplace name. The inverse of
384
393
  * marketplaceNameFor(): "agents-cli" → user, "agents-project" → project,
385
- * "agents-<alias>" → extra. The per-version marketplace operations only key off
386
- * the name (never spec.root), but we resolve the real source root anyway so the
387
- * spec is honest for any caller that inspects it.
394
+ * "agents-system" → system, "agents-<alias>" → extra. The per-version
395
+ * marketplace operations only key off the name (never spec.root), but we
396
+ * resolve the real source root anyway so the spec is honest for any caller that
397
+ * inspects it (e.g. descriptionFor, which would otherwise label the system
398
+ * marketplace as an extra repo named "system").
388
399
  */
389
- function marketplaceSpecForName(name, cwd = process.cwd()) {
400
+ export function marketplaceSpecForName(name, cwd = process.cwd()) {
390
401
  if (!name || name === MARKETPLACE_NAME)
391
402
  return { kind: 'user' };
403
+ if (name === SYSTEM_MARKETPLACE_NAME) {
404
+ return { kind: 'system', root: getSystemPluginsDir() };
405
+ }
392
406
  if (name === PROJECT_MARKETPLACE_NAME) {
393
407
  return { kind: 'project', root: getProjectPluginsDir(cwd) ?? '' };
394
408
  }
@@ -1096,10 +1110,38 @@ export async function installPlugin(spec) {
1096
1110
  throw new Error(`Installed source has no valid .claude-plugin/plugin.json`);
1097
1111
  }
1098
1112
  const capabilities = inspectPluginCapabilities(targetRoot);
1099
- // Persist source for future updates
1100
- fs.writeFileSync(path.join(targetRoot, SOURCE_FILE), JSON.stringify({ source, isGit: !isLocalPath }), 'utf-8');
1113
+ // Persist source for future updates. `version` records the manifest version
1114
+ // at pull time a baseline that lets the heal path tell "central is an
1115
+ // untouched copy of upstream" (safe to fast-forward) from "the user edited
1116
+ // it" (leave alone) without hashing the whole tree.
1117
+ fs.writeFileSync(path.join(targetRoot, SOURCE_FILE), JSON.stringify({ source, isGit: !isLocalPath, version: manifest.version }), 'utf-8');
1101
1118
  return { name: manifest.name, root: targetRoot, isNew, capabilities };
1102
1119
  }
1120
+ /** Read a plugin's `.source` provenance, or null when absent/unreadable. */
1121
+ export function readPluginSourceInfo(root) {
1122
+ const f = path.join(root, SOURCE_FILE);
1123
+ if (!fs.existsSync(f))
1124
+ return null;
1125
+ try {
1126
+ return JSON.parse(fs.readFileSync(f, 'utf-8'));
1127
+ }
1128
+ catch {
1129
+ return null;
1130
+ }
1131
+ }
1132
+ /**
1133
+ * Resolve the CURRENT upstream manifest version for a local-sourced plugin
1134
+ * (the `.system`/local-path case). Returns null for git sources — reading their
1135
+ * upstream version would need a network fetch, so git plugins are refreshed only
1136
+ * via the explicit `agents plugins update`.
1137
+ */
1138
+ export function getUpstreamManifestVersion(info) {
1139
+ if (info.isGit)
1140
+ return null;
1141
+ const resolved = info.source.replace(/^~/, homeDir());
1142
+ const m = loadPluginManifest(resolved);
1143
+ return m?.version ?? null;
1144
+ }
1103
1145
  /**
1104
1146
  * Update an installed plugin by re-pulling from its original source.
1105
1147
  * Returns true if the update succeeded.
@@ -1136,11 +1178,14 @@ export async function updatePlugin(name) {
1136
1178
  : null;
1137
1179
  fs.rmSync(plugin.root, { recursive: true, force: true });
1138
1180
  fs.cpSync(resolvedSource, plugin.root, { recursive: true });
1139
- fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify(sourceInfo), 'utf-8');
1140
1181
  if (userConfigBackup !== null) {
1141
1182
  fs.writeFileSync(userConfigPath, userConfigBackup, 'utf-8');
1142
1183
  }
1143
1184
  }
1185
+ // Re-stamp .source with the freshly pulled manifest version so the baseline
1186
+ // tracks what's now on disk (keeps the heal "unmodified?" check honest).
1187
+ const freshVersion = loadPluginManifest(plugin.root)?.version;
1188
+ fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify({ ...sourceInfo, version: freshVersion }), 'utf-8');
1144
1189
  }
1145
1190
  catch (err) {
1146
1191
  return { success: false, error: err.message };
@@ -47,6 +47,7 @@ import * as path from 'path';
47
47
  import { supports } from './capabilities.js';
48
48
  import { getEnabledExtraRepos, getExtraPluginsDir, getPluginsDir, getProjectAgentsDir, getProjectPluginsDir, getSystemPluginsDir, } from './state.js';
49
49
  import { getVersionHomePath } from './versions.js';
50
+ import { toPortableKey } from './platform/index.js';
50
51
  import { transformSubagentForClaude } from './subagents.js';
51
52
  import { compileRulesForProject } from './rules/compile.js';
52
53
  import { discoverPluginsInDir, hasPluginExecSurfaces, inspectPluginCapabilities } from './plugins.js';
@@ -88,15 +89,17 @@ export function runLaunchSync(opts) {
88
89
  }
89
90
  /**
90
91
  * Path of the shim's skip-fast sentinel for this (agent, version, cwd) tuple.
91
- * Must match the SHIM-SIDE format in src/lib/shims.ts (PROJECT_SLUG derivation):
92
- * slug = PWD with `/` `_` and ` ` `_`
92
+ * Must match the SHIM-SIDE format in src/lib/shims.ts (PROJECT_SLUG derivation),
93
+ * which is the canonical `toPortableKey` mapping: drop the Windows drive colon
94
+ * and fold `\`, `/`, and ` ` → `_`. On POSIX this is byte-identical to the old
95
+ * `/` and ` ` → `_` slug; on Windows it yields a legal filename (no `C:\`).
93
96
  *
94
97
  * Cache leak note: this dir accumulates one zero-byte file per
95
98
  * (agent, version, project) tuple ever launched. Disk impact is negligible
96
99
  * (inodes only). A periodic GC belongs in `agents prune` — follow-up.
97
100
  */
98
101
  function launchSentinelPath(agent, version, cwd) {
99
- const slug = cwd.replace(/\//g, '_').replace(/ /g, '_');
102
+ const slug = toPortableKey(cwd);
100
103
  // Prefer $HOME (respects test overrides + matches bash's $HOME expansion in
101
104
  // the shim), fall back to os.homedir() so the lookup never resolves to '/'
102
105
  // if HOME is somehow unset.
@@ -11,6 +11,7 @@ import * as path from 'path';
11
11
  import * as os from 'os';
12
12
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
13
13
  import { getRoutinesDir, getUserAgentsDir } from './state.js';
14
+ import { createLink } from './platform/index.js';
14
15
  function resolveRealHome() {
15
16
  const home = os.homedir();
16
17
  try {
@@ -126,9 +127,11 @@ export function symlinkAllowedDirs(overlayHome, dirs) {
126
127
  fs.mkdirSync(parentDir, { recursive: true });
127
128
  if (!fs.existsSync(symlinkTarget)) {
128
129
  try {
129
- fs.symlinkSync(realPath, symlinkTarget);
130
+ // createLink uses a junction for directories on Windows (no elevation),
131
+ // a plain symlink on POSIX — allowed dirs are directories.
132
+ createLink(realPath, symlinkTarget);
130
133
  }
131
- catch { /* symlink already exists */ }
134
+ catch { /* link already exists or refused */ }
132
135
  }
133
136
  }
134
137
  }
@@ -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
- await execFileAsync('npm', ['install', '-g', '--prefix', prefix, spec, '--ignore-scripts']);
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
- await execFileAsync('bun', ['add', '-g', spec]);
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) {
@@ -45,6 +45,10 @@ export interface QueryOptions {
45
45
  /** Match any session whose cwd equals this or is a descendant of it. */
46
46
  cwdPrefix?: string;
47
47
  project?: string;
48
+ /** Match the full session id or short id, case-insensitively (exact). */
49
+ idExact?: string;
50
+ /** Match sessions whose id or short id begins with this (case-insensitive prefix). */
51
+ idPrefix?: string;
48
52
  sinceMs?: number;
49
53
  untilMs?: number;
50
54
  limit?: number;
@@ -118,6 +122,16 @@ export declare function upsertSessionsBatch(entries: Array<{
118
122
  * Leaves FTS5 content/topic/project untouched — cheap to call every run.
119
123
  */
120
124
  export declare function syncLabels(labelMap: Map<string, string | null>): number;
125
+ /**
126
+ * Sync topics (session titles) for a set of sessions, keyed by id. For agents
127
+ * whose human-readable title lives in a side index that updates independently
128
+ * of the transcript (Codex `session_index.jsonl`), the per-file scan can't see
129
+ * a title that lands later. This applies those titles by id, updating both
130
+ * `sessions.topic` and the FTS5 topic column. Only ever sets a non-empty title
131
+ * and only when it differs from the stored value — cheap to call every run.
132
+ * Returns the number of rows updated.
133
+ */
134
+ export declare function syncTopics(topicMap: Map<string, string>): number;
121
135
  /** Query sessions from the database, applying filters and ordering by timestamp descending. */
122
136
  export declare function querySessions(options?: QueryOptions): SessionMeta[];
123
137
  /** Count sessions matching the given filter options. */
@@ -160,6 +174,15 @@ export declare function getAllFilePaths(): Set<string>;
160
174
  export declare function getSessionsByFilePaths(paths: string[]): Map<string, SessionMeta>;
161
175
  /** Look up a single session by its unique ID. */
162
176
  export declare function getSessionById(id: string): SessionMeta | null;
177
+ /**
178
+ * Resolve a full-or-partial session id against the index, exact-first then
179
+ * prefix — the DB-backed equivalent of resolveSessionById() that runs over the
180
+ * SQLite table instead of a pre-loaded array. Matches both the full id and the
181
+ * short id. An exact hit short-circuits so a complete id never also drags in its
182
+ * prefix siblings. `scope` narrows by agent / version / project (cwd) so an
183
+ * ambiguous prefix disambiguates against the caller's context.
184
+ */
185
+ export declare function findSessionsById(idQuery: string, scope?: Pick<QueryOptions, 'agent' | 'version' | 'cwd' | 'project'>): SessionMeta[];
163
186
  /** A single full-text search result with ranking score. */
164
187
  export interface FtsHit {
165
188
  sessionId: string;
@@ -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,
@@ -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
@@ -36,6 +36,27 @@ export interface ScanProgress {
36
36
  parsed: number;
37
37
  total: number;
38
38
  }
39
+ /** Lightweight metadata extracted from a Claude JSONL file during incremental scan. */
40
+ interface ClaudeSessionScan {
41
+ timestamp?: string;
42
+ cwd?: string;
43
+ gitBranch?: string;
44
+ version?: string;
45
+ topic?: string;
46
+ messageCount: number;
47
+ tokenCount?: number;
48
+ /** Total USD cost accumulated from per-(model, direction) token usage. */
49
+ costUsd?: number;
50
+ /** Wall-clock duration in ms between the first and last timestamped event. */
51
+ durationMs?: number;
52
+ /**
53
+ * Value of the JSONL `entrypoint` field on the first event that carries it.
54
+ * 'cli' for real interactive sessions, 'sdk-cli' for team-spawned ones.
55
+ */
56
+ entrypoint?: string;
57
+ /** Concatenated user message text, ready to hand to FTS5. */
58
+ contentText?: string;
59
+ }
39
60
  /**
40
61
  * Discover sessions. Scans only files whose (mtime, size) have changed since
41
62
  * the last run; everything else is served from the SQLite cache.
@@ -77,7 +98,12 @@ export declare function getAgentSessionDirs(agent: string, subdir: string): stri
77
98
  * recent startedAt.
78
99
  */
79
100
  export declare function buildClaudeLabelMap(): Map<string, string | null>;
101
+ /** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
102
+ export declare function parseCodexThreadNameIndex(raw: string): Map<string, string>;
103
+ /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
104
+ export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessionScan>;
80
105
  /** Read up to maxLines non-empty lines from the beginning of a file. */
81
106
  export declare function readFirstLines(filePath: string, maxLines: number): Promise<string[]>;
82
107
  /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
83
108
  export declare function parseTimeFilter(input: string): number;
109
+ export {};
@@ -21,7 +21,7 @@ import { getConfigSymlinkVersion } from '../shims.js';
21
21
  import { SESSION_AGENTS } from './types.js';
22
22
  import { extractSessionTopic } from './prompt.js';
23
23
  import { costOfUsage } from '../pricing/index.js';
24
- import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
24
+ import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
25
25
  const HOME = os.homedir();
26
26
  // Versions can live under either repo: the user repo (current canonical
27
27
  // location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
@@ -486,8 +486,14 @@ async function scanCodexIncremental(onProgress) {
486
486
  }
487
487
  }
488
488
  const changed = filterChangedFiles(filePaths);
489
- if (changed.length === 0)
489
+ // Codex keeps human-readable titles (`thread_name`) in `session_index.jsonl`,
490
+ // which updates independently of the rollout files — apply them by id on every
491
+ // scan so a title that lands after a session was first indexed still surfaces.
492
+ const titles = readCodexThreadNames();
493
+ if (changed.length === 0) {
494
+ syncTopics(titles);
490
495
  return;
496
+ }
491
497
  onProgress?.({ agent: 'codex', parsed: 0, total: changed.length });
492
498
  const entries = [];
493
499
  const touched = [];
@@ -498,6 +504,10 @@ async function scanCodexIncremental(onProgress) {
498
504
  const result = await readCodexMeta(filePath, account, currentVersion);
499
505
  if (result && !seen.has(result.meta.id)) {
500
506
  seen.add(result.meta.id);
507
+ // Prefer the Codex-generated title over the first-prompt fallback.
508
+ const title = titles.get(result.meta.id);
509
+ if (title)
510
+ result.meta.topic = title;
501
511
  entries.push({ meta: result.meta, content: result.content, scan });
502
512
  }
503
513
  else {
@@ -512,6 +522,48 @@ async function scanCodexIncremental(onProgress) {
512
522
  }
513
523
  upsertSessionsBatch(entries);
514
524
  recordScans(touched);
525
+ // Catch sessions whose rollout file was unchanged but gained a title since the
526
+ // last scan (the index changed, the transcript did not).
527
+ syncTopics(titles);
528
+ }
529
+ /** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
530
+ export function parseCodexThreadNameIndex(raw) {
531
+ const titles = new Map();
532
+ for (const line of raw.split('\n')) {
533
+ if (!line.trim())
534
+ continue;
535
+ try {
536
+ const entry = JSON.parse(line);
537
+ const id = typeof entry.id === 'string' ? entry.id : '';
538
+ const name = typeof entry.thread_name === 'string' ? entry.thread_name.trim() : '';
539
+ if (id && name)
540
+ titles.set(id, name);
541
+ }
542
+ catch {
543
+ // skip malformed line
544
+ }
545
+ }
546
+ return titles;
547
+ }
548
+ /**
549
+ * Read Codex session titles across every Codex home (live + versioned). The
550
+ * `session_index.jsonl` file sits beside each `sessions/` rollout tree.
551
+ */
552
+ function readCodexThreadNames() {
553
+ const titles = new Map();
554
+ for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
555
+ const indexPath = path.join(path.dirname(sessionsDir), 'session_index.jsonl');
556
+ let raw;
557
+ try {
558
+ raw = fs.readFileSync(indexPath, 'utf-8');
559
+ }
560
+ catch {
561
+ continue; // no index in this home
562
+ }
563
+ for (const [id, name] of parseCodexThreadNameIndex(raw))
564
+ titles.set(id, name);
565
+ }
566
+ return titles;
515
567
  }
516
568
  /** Stream-parse a single Codex JSONL file to extract session metadata. */
517
569
  async function readCodexMeta(filePath, account, currentVersion) {
@@ -1240,7 +1292,7 @@ function extractHermesMessageText(content) {
1240
1292
  .trim();
1241
1293
  }
1242
1294
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
1243
- async function scanClaudeSession(filePath) {
1295
+ export async function scanClaudeSession(filePath) {
1244
1296
  const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
1245
1297
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1246
1298
  let timestamp;
@@ -1248,6 +1300,10 @@ async function scanClaudeSession(filePath) {
1248
1300
  let gitBranch;
1249
1301
  let version;
1250
1302
  let topic;
1303
+ // Explicit session titles: `/rename` writes a `custom-title` event; Claude
1304
+ // auto-generates an `ai-title`. Both can repeat across the file — last wins.
1305
+ let customTitle;
1306
+ let aiTitle;
1251
1307
  let entrypoint;
1252
1308
  let messageCount = 0;
1253
1309
  let tokenCount = 0;
@@ -1291,6 +1347,18 @@ async function scanClaudeSession(filePath) {
1291
1347
  gitBranch = parsed.gitBranch || undefined;
1292
1348
  version = parsed.version || undefined;
1293
1349
  }
1350
+ if (parsed.type === 'custom-title') {
1351
+ const t = typeof parsed.customTitle === 'string' ? parsed.customTitle.trim() : '';
1352
+ if (t)
1353
+ customTitle = t;
1354
+ continue;
1355
+ }
1356
+ if (parsed.type === 'ai-title') {
1357
+ const t = typeof parsed.aiTitle === 'string' ? parsed.aiTitle.trim() : '';
1358
+ if (t)
1359
+ aiTitle = t;
1360
+ continue;
1361
+ }
1294
1362
  if (parsed.type === 'user') {
1295
1363
  const text = extractClaudeUserText(parsed);
1296
1364
  if (text) {
@@ -1344,12 +1412,15 @@ async function scanClaudeSession(filePath) {
1344
1412
  const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
1345
1413
  ? lastTsMs - firstTsMs
1346
1414
  : undefined;
1415
+ // Prefer an explicit session title (user `/rename` > Claude auto-title) over
1416
+ // the first-prompt topic.
1417
+ const resolvedTopic = customTitle || aiTitle || topic;
1347
1418
  return {
1348
1419
  timestamp,
1349
1420
  cwd,
1350
1421
  gitBranch,
1351
1422
  version,
1352
- topic,
1423
+ topic: resolvedTopic,
1353
1424
  entrypoint,
1354
1425
  messageCount,
1355
1426
  tokenCount: sawTokenCount ? tokenCount : undefined,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Human-readable "time since" formatting for session timestamps. Lives here (not
3
+ * inline in `sessions.ts`) so both the session list renderer and the remote
4
+ * offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
5
+ * `remote.ts`, so a back-import would cycle.
6
+ */
7
+ export declare function formatRelativeTime(isoTimestamp: string): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Human-readable "time since" formatting for session timestamps. Lives here (not
3
+ * inline in `sessions.ts`) so both the session list renderer and the remote
4
+ * offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
5
+ * `remote.ts`, so a back-import would cycle.
6
+ */
7
+ export function formatRelativeTime(isoTimestamp) {
8
+ const now = Date.now();
9
+ const then = new Date(isoTimestamp).getTime();
10
+ if (isNaN(then))
11
+ return isoTimestamp;
12
+ const diffMs = now - then;
13
+ const diffMin = Math.floor(diffMs / 60_000);
14
+ const diffHrs = Math.floor(diffMs / 3_600_000);
15
+ const diffDays = Math.floor(diffMs / 86_400_000);
16
+ if (diffMin < 1)
17
+ return 'just now';
18
+ if (diffMin < 60)
19
+ return `${diffMin} min ago`;
20
+ if (diffHrs < 24)
21
+ return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
22
+ if (diffDays < 7)
23
+ return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
24
+ // Older: show date
25
+ const d = new Date(then);
26
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
27
+ return `${months[d.getMonth()]} ${d.getDate()}`;
28
+ }
@@ -0,0 +1,61 @@
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.
5
+ */
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
+ export declare function shellQuote(s: string): string;
10
+ /**
11
+ * Strip the `--host`/`-H` flag (and its value) from a raw `agents sessions` argv,
12
+ * leaving the args to forward to the remote unchanged. The remote runs the same
13
+ * binary, so every other flag (`--since`, `--last`, `--json`, query, …) carries
14
+ * over for free. Handles every form commander accepts: `--host h`, `--host=h`,
15
+ * `-H h`, `-H=h`, and the glued short form `-Hh`.
16
+ *
17
+ * @param argv full process argv; the sessions args begin at index 2
18
+ * (`[runtime, script, 'sessions', ...]`).
19
+ */
20
+ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>): string[];
21
+ /**
22
+ * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
23
+ * are quoted for the inner login shell, then the whole `agents …` invocation is
24
+ * quoted again so it survives `bash -lc <...>`.
25
+ */
26
+ export declare function buildRemoteCommand(forwardedArgs: string[]): string;
27
+ /** The four outcomes of one `ssh <host> agents sessions …` invocation. */
28
+ export type SshOutcome = 'ok' | 'unreachable' | 'query-failed' | 'spawn-error';
29
+ /**
30
+ * Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
31
+ * connection-layer failures (host down, timeout, refused, auth, changed host
32
+ * key) — distinct from any other non-zero, which is the remote `agents sessions`
33
+ * exit code forwarded back (the query ran but failed). The two must be handled
34
+ * differently: 255 may fall back to cache, a forwarded failure must surface.
35
+ */
36
+ export declare function classifySshFailure(res: {
37
+ error?: Error | null;
38
+ status: number | null;
39
+ }): SshOutcome;
40
+ /**
41
+ * Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
42
+ * are hashed so distinct queries cache independently; the host stays readable in
43
+ * the filename (sanitised so `user@host` and aliases are filesystem-safe).
44
+ */
45
+ export declare function remoteCachePath(host: string, forwardedArgs: string[]): string;
46
+ /** Banner shown above replayed cache rows when the peer is offline. */
47
+ export declare function formatStaleBanner(host: string, mtimeMs: number): string;
48
+ /** Message shown when a host is unreachable and there is no cache to fall back to. */
49
+ export declare function formatUnreachable(host: string): string;
50
+ /**
51
+ * Run the current `agents sessions` invocation on one or more remote machines over
52
+ * SSH, writing each remote's output to the terminal. A successful fetch is cached;
53
+ * an unreachable host falls back to that cache (with a stale banner) when present.
54
+ * Sets `process.exitCode = 1` if any host could not be answered (live or cached).
55
+ * Reads the invocation from `process.argv` (override via `argv` for testing).
56
+ *
57
+ * Output is captured rather than `stdio: 'inherit'`-streamed so it can be cached.
58
+ * Session output is small and the remote returns quickly, so buffering is
59
+ * imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
60
+ */
61
+ export declare function runRemoteSessions(hosts: string[], argv?: string[]): void;