@phnx-labs/agents-cli 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +78 -39
  2. package/README.md +6 -6
  3. package/dist/commands/alias.js +2 -2
  4. package/dist/commands/browser-picker.d.ts +21 -0
  5. package/dist/commands/browser-picker.js +114 -0
  6. package/dist/commands/browser.js +546 -75
  7. package/dist/commands/commands.js +72 -22
  8. package/dist/commands/daemon.js +2 -2
  9. package/dist/commands/fork.js +2 -2
  10. package/dist/commands/hooks.js +71 -26
  11. package/dist/commands/mcp.js +81 -39
  12. package/dist/commands/plugins.js +48 -15
  13. package/dist/commands/prune.js +23 -1
  14. package/dist/commands/pull.js +3 -3
  15. package/dist/commands/repo.js +1 -1
  16. package/dist/commands/routines.js +2 -2
  17. package/dist/commands/secrets.js +37 -1
  18. package/dist/commands/sessions.js +62 -19
  19. package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
  20. package/dist/commands/{init.js → setup.js} +22 -21
  21. package/dist/commands/skills.js +60 -19
  22. package/dist/commands/subagents.js +41 -13
  23. package/dist/commands/utils.d.ts +16 -0
  24. package/dist/commands/utils.js +32 -0
  25. package/dist/commands/view.js +61 -16
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +17 -20
  28. package/dist/lib/agents.js +2 -2
  29. package/dist/lib/auto-pull-worker.js +2 -3
  30. package/dist/lib/auto-pull.js +2 -2
  31. package/dist/lib/browser/cdp.d.ts +7 -1
  32. package/dist/lib/browser/cdp.js +29 -1
  33. package/dist/lib/browser/chrome.js +5 -2
  34. package/dist/lib/browser/devices.d.ts +4 -0
  35. package/dist/lib/browser/devices.js +27 -0
  36. package/dist/lib/browser/drivers/local.js +9 -4
  37. package/dist/lib/browser/drivers/ssh.js +9 -2
  38. package/dist/lib/browser/ipc.js +144 -23
  39. package/dist/lib/browser/profiles.d.ts +5 -2
  40. package/dist/lib/browser/profiles.js +77 -37
  41. package/dist/lib/browser/service.d.ts +81 -13
  42. package/dist/lib/browser/service.js +738 -131
  43. package/dist/lib/browser/types.d.ts +81 -3
  44. package/dist/lib/browser/types.js +16 -0
  45. package/dist/lib/cloud/rush.js +2 -2
  46. package/dist/lib/cloud/store.js +2 -2
  47. package/dist/lib/commands.d.ts +1 -0
  48. package/dist/lib/commands.js +6 -2
  49. package/dist/lib/daemon.js +2 -3
  50. package/dist/lib/doctor-diff.js +4 -4
  51. package/dist/lib/events.js +2 -2
  52. package/dist/lib/hooks.d.ts +11 -7
  53. package/dist/lib/hooks.js +125 -49
  54. package/dist/lib/migrate.d.ts +1 -1
  55. package/dist/lib/migrate.js +1178 -21
  56. package/dist/lib/models.js +2 -2
  57. package/dist/lib/permissions.d.ts +8 -8
  58. package/dist/lib/permissions.js +8 -8
  59. package/dist/lib/plugins.d.ts +30 -1
  60. package/dist/lib/plugins.js +75 -3
  61. package/dist/lib/pty-server.js +9 -10
  62. package/dist/lib/resources/hooks.d.ts +5 -1
  63. package/dist/lib/resources/hooks.js +21 -4
  64. package/dist/lib/rotate.js +3 -4
  65. package/dist/lib/session/active.d.ts +3 -0
  66. package/dist/lib/session/active.js +92 -6
  67. package/dist/lib/session/cloud.js +2 -2
  68. package/dist/lib/session/db.js +8 -3
  69. package/dist/lib/session/discover.js +30 -15
  70. package/dist/lib/session/team-filter.js +2 -2
  71. package/dist/lib/shims.d.ts +2 -2
  72. package/dist/lib/shims.js +6 -6
  73. package/dist/lib/skills.js +6 -2
  74. package/dist/lib/state.d.ts +86 -14
  75. package/dist/lib/state.js +150 -23
  76. package/dist/lib/subagents.d.ts +28 -0
  77. package/dist/lib/subagents.js +98 -1
  78. package/dist/lib/sync-manifest.d.ts +1 -1
  79. package/dist/lib/sync-manifest.js +3 -3
  80. package/dist/lib/teams/persistence.js +15 -5
  81. package/dist/lib/teams/registry.js +2 -2
  82. package/dist/lib/types.d.ts +32 -3
  83. package/dist/lib/types.js +3 -3
  84. package/dist/lib/usage.js +2 -2
  85. package/dist/lib/versions.js +20 -21
  86. package/package.json +1 -1
  87. package/scripts/postinstall.js +1 -1
@@ -11,8 +11,8 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import { execFileSync } from 'child_process';
13
13
  import { getVersionDir } from './versions.js';
14
- import { getAgentsDir } from './state.js';
15
- const CACHE_PATH = path.join(getAgentsDir(), '.models-cache.json');
14
+ import { getModelsCachePath } from './state.js';
15
+ const CACHE_PATH = getModelsCachePath();
16
16
  /**
17
17
  * Bump when the extractor logic changes shape in an incompatible way so cached
18
18
  * catalogs from older agents-cli builds are re-extracted.
@@ -42,26 +42,26 @@ export declare function discoverPermissionGroups(): PermissionGroupInfo[];
42
42
  */
43
43
  export declare function getTotalPermissionRuleCount(): number;
44
44
  /**
45
- * A permission set recipe — names a set and lists which groups it composes.
46
- * Lives at ~/.agents/permissions/sets/<name>.yaml.
45
+ * A permission preset recipe — names a preset and lists which groups it composes.
46
+ * Lives at ~/.agents/permissions/presets/<name>.yaml.
47
47
  */
48
- export interface PermissionSetRecipe {
48
+ export interface PermissionPresetRecipe {
49
49
  name: string;
50
50
  description?: string;
51
51
  includes: string[];
52
52
  }
53
53
  /** Env var that selects which set recipe to apply at sync time. */
54
- export declare const PERMISSION_SET_ENV_VAR = "AGENTS_PERMISSION_SET";
54
+ export declare const PERMISSION_PRESET_ENV_VAR = "AGENTS_PERMISSION_PRESET";
55
55
  /**
56
- * Read a permission set recipe by name from ~/.agents/permissions/sets/.
56
+ * Read a permission preset recipe by name from ~/.agents/permissions/presets/.
57
57
  * Returns null if the recipe file is missing or malformed.
58
58
  */
59
- export declare function readPermissionSetRecipe(name: string): PermissionSetRecipe | null;
59
+ export declare function readPermissionPresetRecipe(name: string): PermissionPresetRecipe | null;
60
60
  /**
61
- * Return the active permission set name from AGENTS_PERMISSION_SET env var,
61
+ * Return the active permission preset name from AGENTS_PERMISSION_PRESET env var,
62
62
  * or null if unset. Caller decides the default behavior when null.
63
63
  */
64
- export declare function getActivePermissionSetName(): string | null;
64
+ export declare function getActivePermissionPresetName(): string | null;
65
65
  /**
66
66
  * Build a PermissionSet from selected groups.
67
67
  * Concatenates allow/deny rules from each group.
@@ -159,15 +159,15 @@ export function getTotalPermissionRuleCount() {
159
159
  return groups.reduce((sum, g) => sum + g.ruleCount, 0);
160
160
  }
161
161
  /** Env var that selects which set recipe to apply at sync time. */
162
- export const PERMISSION_SET_ENV_VAR = 'AGENTS_PERMISSION_SET';
162
+ export const PERMISSION_PRESET_ENV_VAR = 'AGENTS_PERMISSION_PRESET';
163
163
  /**
164
- * Read a permission set recipe by name from ~/.agents/permissions/sets/.
164
+ * Read a permission preset recipe by name from ~/.agents/permissions/presets/.
165
165
  * Returns null if the recipe file is missing or malformed.
166
166
  */
167
- export function readPermissionSetRecipe(name) {
168
- const setsDir = path.join(getPermissionsDir(), 'sets');
167
+ export function readPermissionPresetRecipe(name) {
168
+ const presetsDir = path.join(getPermissionsDir(), 'presets');
169
169
  for (const ext of ['.yaml', '.yml']) {
170
- const filePath = safeJoin(setsDir, name + ext);
170
+ const filePath = safeJoin(presetsDir, name + ext);
171
171
  if (!fs.existsSync(filePath))
172
172
  continue;
173
173
  try {
@@ -190,11 +190,11 @@ export function readPermissionSetRecipe(name) {
190
190
  return null;
191
191
  }
192
192
  /**
193
- * Return the active permission set name from AGENTS_PERMISSION_SET env var,
193
+ * Return the active permission preset name from AGENTS_PERMISSION_PRESET env var,
194
194
  * or null if unset. Caller decides the default behavior when null.
195
195
  */
196
- export function getActivePermissionSetName() {
197
- const v = process.env[PERMISSION_SET_ENV_VAR];
196
+ export function getActivePermissionPresetName() {
197
+ const v = process.env[PERMISSION_PRESET_ENV_VAR];
198
198
  return v && v.trim() ? v.trim() : null;
199
199
  }
200
200
  /**
@@ -73,7 +73,36 @@ export declare function removePluginFromVersion(pluginName: string, pluginRoot:
73
73
  };
74
74
  /**
75
75
  * Remove orphaned plugin skill directories from a version home.
76
+ * Soft-deletes to ~/.agents/.trash/plugins/.
76
77
  * An orphan is a skill dir with the plugin prefix pattern (name--skill)
77
78
  * where the plugin no longer exists in ~/.agents/plugins/.
78
79
  */
79
- export declare function cleanOrphanedPluginSkills(agent: AgentId, versionHome: string, activePluginNames: Set<string>): string[];
80
+ export declare function cleanOrphanedPluginSkills(agent: AgentId, versionHome: string, activePluginNames: Set<string>, version?: string): string[];
81
+ export interface VersionPluginDiff {
82
+ agent: AgentId;
83
+ version: string;
84
+ orphans: string[];
85
+ }
86
+ /**
87
+ * Compare a version home's plugin skills against discovered plugins.
88
+ * Returns orphan plugin skill names (pattern: pluginName--skillName).
89
+ */
90
+ export declare function diffVersionPlugins(agent: AgentId, version: string): VersionPluginDiff;
91
+ /**
92
+ * Iterate all (agent, version) pairs that support plugins and are installed.
93
+ */
94
+ export declare function iterPluginsCapableVersions(filter?: {
95
+ agent?: AgentId;
96
+ version?: string;
97
+ }): Array<{
98
+ agent: AgentId;
99
+ version: string;
100
+ }>;
101
+ /**
102
+ * Remove a single orphan plugin skill from a version home.
103
+ * Soft-deletes to ~/.agents/.trash/plugins/.
104
+ */
105
+ export declare function removePluginSkillFromVersion(agent: AgentId, version: string, skillName: string): {
106
+ success: boolean;
107
+ error?: string;
108
+ };
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
- import { getPluginsDir } from './state.js';
10
+ import { getPluginsDir, getTrashPluginsDir } from './state.js';
11
+ import { listInstalledVersions, getVersionHomePath } from './versions.js';
11
12
  import { AGENTS, PLUGINS_CAPABLE_AGENTS } from './agents.js';
12
13
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
13
14
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
@@ -527,10 +528,11 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
527
528
  }
528
529
  /**
529
530
  * Remove orphaned plugin skill directories from a version home.
531
+ * Soft-deletes to ~/.agents/.trash/plugins/.
530
532
  * An orphan is a skill dir with the plugin prefix pattern (name--skill)
531
533
  * where the plugin no longer exists in ~/.agents/plugins/.
532
534
  */
533
- export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames) {
535
+ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames, version) {
534
536
  const removed = [];
535
537
  const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
536
538
  if (!fs.existsSync(skillsDir))
@@ -546,7 +548,11 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames)
546
548
  const pluginName = entry.name.slice(0, dashIdx);
547
549
  if (!activePluginNames.has(pluginName)) {
548
550
  try {
549
- fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true });
551
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
552
+ const trashDir = path.join(getTrashPluginsDir(), agent, version || 'unknown', entry.name);
553
+ const trashDest = path.join(trashDir, stamp);
554
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
555
+ fs.renameSync(path.join(skillsDir, entry.name), trashDest);
550
556
  removed.push(entry.name);
551
557
  }
552
558
  catch {
@@ -556,3 +562,69 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames)
556
562
  }
557
563
  return removed;
558
564
  }
565
+ /**
566
+ * Compare a version home's plugin skills against discovered plugins.
567
+ * Returns orphan plugin skill names (pattern: pluginName--skillName).
568
+ */
569
+ export function diffVersionPlugins(agent, version) {
570
+ const versionHome = getVersionHomePath(agent, version);
571
+ const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
572
+ const orphans = [];
573
+ if (!fs.existsSync(skillsDir)) {
574
+ return { agent, version, orphans };
575
+ }
576
+ const activePlugins = new Set(discoverPlugins().map(p => p.name));
577
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
578
+ for (const entry of entries) {
579
+ if (!entry.isDirectory())
580
+ continue;
581
+ const dashIdx = entry.name.indexOf('--');
582
+ if (dashIdx === -1)
583
+ continue;
584
+ const pluginName = entry.name.slice(0, dashIdx);
585
+ if (!activePlugins.has(pluginName)) {
586
+ orphans.push(entry.name);
587
+ }
588
+ }
589
+ return { agent, version, orphans: orphans.sort() };
590
+ }
591
+ /**
592
+ * Iterate all (agent, version) pairs that support plugins and are installed.
593
+ */
594
+ export function iterPluginsCapableVersions(filter) {
595
+ const pairs = [];
596
+ const agents = filter?.agent ? [filter.agent] : PLUGINS_CAPABLE_AGENTS;
597
+ for (const agent of agents) {
598
+ if (!PLUGINS_CAPABLE_AGENTS.includes(agent))
599
+ continue;
600
+ const versions = listInstalledVersions(agent);
601
+ for (const version of versions) {
602
+ if (filter?.version && filter.version !== version)
603
+ continue;
604
+ pairs.push({ agent, version });
605
+ }
606
+ }
607
+ return pairs;
608
+ }
609
+ /**
610
+ * Remove a single orphan plugin skill from a version home.
611
+ * Soft-deletes to ~/.agents/.trash/plugins/.
612
+ */
613
+ export function removePluginSkillFromVersion(agent, version, skillName) {
614
+ const versionHome = getVersionHomePath(agent, version);
615
+ const skillPath = path.join(versionHome, `.${agent}`, 'skills', skillName);
616
+ if (!fs.existsSync(skillPath)) {
617
+ return { success: true };
618
+ }
619
+ try {
620
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
621
+ const trashDir = path.join(getTrashPluginsDir(), agent, version, skillName);
622
+ const trashDest = path.join(trashDir, stamp);
623
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
624
+ fs.renameSync(skillPath, trashDest);
625
+ }
626
+ catch (err) {
627
+ return { success: false, error: err.message };
628
+ }
629
+ return { success: true };
630
+ }
@@ -14,7 +14,7 @@ import * as path from 'path';
14
14
  import * as crypto from 'crypto';
15
15
  import { execFileSync } from 'child_process';
16
16
  import { fileURLToPath } from 'url';
17
- import { getAgentsDir } from './state.js';
17
+ import { getPtyDir as getPtyDirRoot } from './state.js';
18
18
  /**
19
19
  * Capture a stable identifier for a process at the moment it was started.
20
20
  * Used to defeat PID reuse: a kill(pid, ...) is only safe when the process
@@ -53,7 +53,6 @@ export function captureProcessStartTime(pid) {
53
53
  }
54
54
  // --- Constants ---
55
55
  const SENTINEL = '__AGENTS_PTY_DONE__';
56
- const PTY_DIR = 'helpers/pty';
57
56
  const SOCKET_NAME = 'pty.sock';
58
57
  const PID_FILE = 'pty.pid';
59
58
  const LOG_FILE = 'logs.jsonl';
@@ -84,7 +83,7 @@ function buildPtyEnv() {
84
83
  }
85
84
  /** Get the PTY helper directory, creating it if needed. */
86
85
  function getPtyDir() {
87
- const dir = path.join(getAgentsDir(), PTY_DIR);
86
+ const dir = getPtyDirRoot();
88
87
  fs.mkdirSync(dir, { recursive: true });
89
88
  return dir;
90
89
  }
@@ -178,7 +177,7 @@ export async function runPtyServer() {
178
177
  }
179
178
  catch (err) {
180
179
  console.error('node-pty is required for PTY support.');
181
- console.error('Install: cd ' + getAgentsDir() + '/../agents-cli && bun add node-pty');
180
+ console.error('Install: cd ' + '~/agents-cli && bun add node-pty');
182
181
  process.exit(1);
183
182
  }
184
183
  try {
@@ -188,7 +187,7 @@ export async function runPtyServer() {
188
187
  }
189
188
  catch {
190
189
  console.error('@xterm/headless is required for PTY support.');
191
- console.error('Install: cd ' + getAgentsDir() + '/../agents-cli && bun add @xterm/headless');
190
+ console.error('Install: cd ' + '~/agents-cli && bun add @xterm/headless');
192
191
  process.exit(1);
193
192
  }
194
193
  const sessions = new Map();
@@ -493,11 +492,11 @@ export async function runPtyServer() {
493
492
  });
494
493
  conn.on('error', () => { });
495
494
  });
496
- // Lock down ~/.agents-system/ before opening the socket — without this, any local
497
- // user with execute on the parent dir could connect to the socket during
498
- // the listen()-to-chmod() window. macOS BSD AF_UNIX semantics make socket
499
- // mode advisory only, so the parent dir is the real boundary.
500
- const agentsDir = getAgentsDir();
495
+ // Lock down the PTY scratch dir before opening the socket — without this,
496
+ // any local user with execute on the parent dir could connect to the socket
497
+ // during the listen()-to-chmod() window. macOS BSD AF_UNIX semantics make
498
+ // socket mode advisory only, so the parent dir is the real boundary.
499
+ const agentsDir = getPtyDirRoot();
501
500
  fs.mkdirSync(agentsDir, { recursive: true });
502
501
  fs.chmodSync(agentsDir, 0o700);
503
502
  // umask covers any inherited group/other bits while listen() is creating
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * HooksHandler - ResourceHandler implementation for hooks.
3
3
  *
4
- * Hooks are declared in hooks.yaml at each layer (system, user, project).
4
+ * Hook declarations live in:
5
+ * - System: ~/.agents-system/hooks.yaml (npm-shipped defaults)
6
+ * - User: `hooks:` section of ~/.agents/agents.yaml
7
+ * - Project: <project>/.agents/hooks.yaml
8
+ *
5
9
  * Resolution: project > user > system (higher layer wins on name conflict).
6
10
  * Non-conflicting hooks from all layers are unioned together.
7
11
  */
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * HooksHandler - ResourceHandler implementation for hooks.
3
3
  *
4
- * Hooks are declared in hooks.yaml at each layer (system, user, project).
4
+ * Hook declarations live in:
5
+ * - System: ~/.agents-system/hooks.yaml (npm-shipped defaults)
6
+ * - User: `hooks:` section of ~/.agents/agents.yaml
7
+ * - Project: <project>/.agents/hooks.yaml
8
+ *
5
9
  * Resolution: project > user > system (higher layer wins on name conflict).
6
10
  * Non-conflicting hooks from all layers are unioned together.
7
11
  */
@@ -10,13 +14,20 @@ import * as path from 'path';
10
14
  import * as yaml from 'yaml';
11
15
  import { getSystemAgentsDir, getUserAgentsDir, getProjectAgentsDir, } from '../state.js';
12
16
  /**
13
- * Get the hooks.yaml path for a given layer directory.
17
+ * Get the hook manifest path for a layer dir. The user layer reads from
18
+ * agents.yaml (hooks: section) since that's where user hooks now live.
19
+ * Other layers continue to use a standalone hooks.yaml.
14
20
  */
15
21
  function getHooksYamlPath(layerDir) {
22
+ if (layerDir === getUserAgentsDir()) {
23
+ return path.join(layerDir, 'agents.yaml');
24
+ }
16
25
  return path.join(layerDir, 'hooks.yaml');
17
26
  }
18
27
  /**
19
- * Parse hooks.yaml from a directory.
28
+ * Parse hooks for a layer directory.
29
+ * - User layer: read `hooks:` section from agents.yaml.
30
+ * - System / project layer: read top-level map from hooks.yaml.
20
31
  * Returns empty object if file doesn't exist or is invalid.
21
32
  */
22
33
  function parseHooksYaml(dir) {
@@ -27,7 +38,13 @@ function parseHooksYaml(dir) {
27
38
  try {
28
39
  const content = fs.readFileSync(manifestPath, 'utf-8');
29
40
  const parsed = yaml.parse(content);
30
- return parsed || {};
41
+ if (!parsed)
42
+ return {};
43
+ if (dir === getUserAgentsDir()) {
44
+ const hooks = parsed.hooks;
45
+ return (hooks && typeof hooks === 'object') ? hooks : {};
46
+ }
47
+ return parsed;
31
48
  }
32
49
  catch {
33
50
  return {};
@@ -8,12 +8,11 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as yaml from 'yaml';
10
10
  import { getAccountInfo } from './agents.js';
11
- import { readMeta, writeMeta, getAgentsDir } from './state.js';
11
+ import { readMeta, writeMeta, getHelpersDir, getUserAgentsDir } from './state.js';
12
12
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
13
13
  import { getUsageInfoByIdentity, getUsageLookupKey, isClaudeAuthValid, } from './usage.js';
14
- const ROTATE_DIR = 'helpers/rotate';
15
14
  function getRotateDir() {
16
- const dir = path.join(getAgentsDir(), ROTATE_DIR);
15
+ const dir = path.join(getHelpersDir(), 'rotate');
17
16
  fs.mkdirSync(dir, { recursive: true });
18
17
  return dir;
19
18
  }
@@ -35,7 +34,7 @@ export function normalizeRunStrategy(value) {
35
34
  /** Read project-local run strategy from the nearest agents.yaml, if present. */
36
35
  export function getProjectRunStrategy(agent, startPath) {
37
36
  let dir = path.resolve(startPath);
38
- const userAgentsYaml = path.join(getAgentsDir(), 'agents.yaml');
37
+ const userAgentsYaml = path.join(getUserAgentsDir(), 'agents.yaml');
39
38
  while (dir !== path.dirname(dir)) {
40
39
  const manifestPath = path.join(dir, 'agents.yaml');
41
40
  if (manifestPath !== userAgentsYaml && fs.existsSync(manifestPath)) {
@@ -8,7 +8,10 @@ export interface ActiveSession {
8
8
  pid?: number;
9
9
  sessionId?: string;
10
10
  cwd?: string;
11
+ /** User-given name from /rename command. */
11
12
  label?: string;
13
+ /** First meaningful line of the initial prompt (extracted topic). */
14
+ topic?: string;
12
15
  sessionFile?: string;
13
16
  startedAtMs?: number;
14
17
  status: ActiveStatus;
@@ -2,7 +2,7 @@
2
2
  * Active-session detection across every context an agent can run in:
3
3
  *
4
4
  * - `terminal` — agents launched from VS Code / Cursor / Codium via the
5
- * agents-cli extension. Published to `~/.agents/runtime/live-terminals.json`
5
+ * agents-cli extension. Published to `~/.agents/.cache/terminals/live-terminals.json`
6
6
  * with PID + session UUID per entry.
7
7
  * - `teams` — agents spawned by `agents teams add`, tracked in
8
8
  * `~/.agents/teams/agents/<id>/meta.json` with a PID the manager polls.
@@ -23,10 +23,12 @@ import { execFile } from 'child_process';
23
23
  import { promisify } from 'util';
24
24
  import { listActiveTasks } from '../cloud/store.js';
25
25
  import { AgentManager } from '../teams/agents.js';
26
- import { getUserAgentsDir } from '../state.js';
26
+ import { getTerminalsDir } from '../state.js';
27
+ import { buildClaudeLabelMap } from './discover.js';
28
+ import { extractSessionTopic } from './prompt.js';
27
29
  const execFileAsync = promisify(execFile);
28
30
  const HOME = os.homedir();
29
- const LIVE_TERMINALS_FILE = path.join(getUserAgentsDir(), 'runtime', 'live-terminals.json');
31
+ const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
30
32
  /**
31
33
  * A process is classified `running` if its session file was touched in the
32
34
  * last 2 minutes. Every Claude/Codex tool-call appends an event, so a
@@ -80,9 +82,9 @@ function readLiveTerminals() {
80
82
  }
81
83
  return Array.from(merged.values());
82
84
  }
83
- /** Convert an absolute cwd to the Claude-project folder name (slashes → dashes). */
85
+ /** Convert an absolute cwd to the Claude-project folder name (slashes and dots → dashes). */
84
86
  function claudeProjectDirName(cwd) {
85
- return cwd.replace(/\//g, '-');
87
+ return cwd.replace(/[/.]/g, '-');
86
88
  }
87
89
  /**
88
90
  * Locate the active Claude session file for a process. If we know the session
@@ -126,6 +128,79 @@ function classifyActivity(sessionFile) {
126
128
  return 'running';
127
129
  }
128
130
  }
131
+ /**
132
+ * Extract the first user message's content from a Claude JSONL file.
133
+ * Reads only the first ~50 lines for speed, since the user message is
134
+ * typically near the top (after system/queue events).
135
+ */
136
+ function extractClaudeUserText(parsed) {
137
+ const msg = parsed.message;
138
+ if (!msg?.content)
139
+ return undefined;
140
+ const content = Array.isArray(msg.content) ? msg.content : [msg.content];
141
+ const texts = [];
142
+ for (const block of content) {
143
+ if (typeof block === 'string')
144
+ texts.push(block);
145
+ else if (block?.type === 'text' && typeof block.text === 'string')
146
+ texts.push(block.text);
147
+ }
148
+ return texts.join('\n').trim() || undefined;
149
+ }
150
+ function quickExtractTopic(sessionFile) {
151
+ let fd;
152
+ try {
153
+ fd = fs.openSync(sessionFile, 'r');
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ try {
159
+ const chunkSize = 256 * 1024;
160
+ const maxBytes = 2 * 1024 * 1024;
161
+ let buffer = '';
162
+ let totalRead = 0;
163
+ let linesChecked = 0;
164
+ const maxLines = 30;
165
+ while (totalRead < maxBytes && linesChecked < maxLines) {
166
+ const chunk = Buffer.alloc(chunkSize);
167
+ const bytesRead = fs.readSync(fd, chunk, 0, chunkSize, totalRead);
168
+ if (bytesRead === 0)
169
+ break;
170
+ totalRead += bytesRead;
171
+ buffer += chunk.toString('utf8', 0, bytesRead);
172
+ let lineStart = 0;
173
+ let lineEnd;
174
+ while ((lineEnd = buffer.indexOf('\n', lineStart)) !== -1 && linesChecked < maxLines) {
175
+ const line = buffer.slice(lineStart, lineEnd);
176
+ lineStart = lineEnd + 1;
177
+ linesChecked++;
178
+ if (!line.trim())
179
+ continue;
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(line);
183
+ }
184
+ catch {
185
+ continue;
186
+ }
187
+ if (parsed.type === 'user') {
188
+ const text = extractClaudeUserText(parsed);
189
+ if (text) {
190
+ const topic = extractSessionTopic(text);
191
+ if (topic)
192
+ return topic;
193
+ }
194
+ }
195
+ }
196
+ buffer = buffer.slice(lineStart);
197
+ }
198
+ }
199
+ finally {
200
+ fs.closeSync(fd);
201
+ }
202
+ return undefined;
203
+ }
129
204
  /** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
130
205
  export async function listTeamsActive() {
131
206
  const mgr = new AgentManager();
@@ -135,6 +210,7 @@ export async function listTeamsActive() {
135
210
  const sessionFile = a.agentType === 'claude' && a.cwd
136
211
  ? findClaudeSessionFile(a.cwd, sessionId ?? undefined)
137
212
  : undefined;
213
+ const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
138
214
  return {
139
215
  context: 'teams',
140
216
  kind: a.agentType,
@@ -142,6 +218,7 @@ export async function listTeamsActive() {
142
218
  sessionId,
143
219
  cwd: a.cwd ?? undefined,
144
220
  label: a.name ?? undefined,
221
+ topic,
145
222
  sessionFile,
146
223
  startedAtMs: a.startedAt.getTime(),
147
224
  status: classifyActivity(sessionFile),
@@ -160,10 +237,16 @@ export async function listTerminalsActive() {
160
237
  const procByPid = new Map();
161
238
  for (const r of await readProcessTable())
162
239
  procByPid.set(r.pid, r);
240
+ // Build label map from Claude's sessions/*.json for /rename support
241
+ const labelMap = buildClaudeLabelMap();
163
242
  return entries.map((t) => {
164
243
  const sessionFile = t.kind === 'claude' && t.cwd
165
244
  ? findClaudeSessionFile(t.cwd, t.sessionId)
166
245
  : undefined;
246
+ // Prefer label from live terminal, fall back to Claude's session label
247
+ const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
248
+ // Extract topic from session file (first meaningful user message)
249
+ const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
167
250
  return {
168
251
  context: 'terminal',
169
252
  kind: t.kind,
@@ -171,7 +254,8 @@ export async function listTerminalsActive() {
171
254
  pid: t.pid,
172
255
  sessionId: t.sessionId,
173
256
  cwd: t.cwd ?? undefined,
174
- label: t.label ?? undefined,
257
+ label,
258
+ topic,
175
259
  sessionFile,
176
260
  startedAtMs: t.startedAtMs,
177
261
  status: classifyActivity(sessionFile),
@@ -355,6 +439,7 @@ export async function listUnattributedActive(attributed) {
355
439
  const { pid, kind } = candidates[i];
356
440
  const cwd = cwds[i];
357
441
  const sessionFile = kind === 'claude' && cwd ? findClaudeSessionFile(cwd) : undefined;
442
+ const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
358
443
  const host = detectHost(pid, procByPid);
359
444
  const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
360
445
  out.push({
@@ -363,6 +448,7 @@ export async function listUnattributedActive(attributed) {
363
448
  host,
364
449
  pid,
365
450
  cwd,
451
+ topic,
366
452
  sessionFile,
367
453
  status: classifyActivity(sessionFile),
368
454
  });
@@ -13,10 +13,10 @@ import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as os from 'os';
15
15
  import * as yaml from 'yaml';
16
- import { getAgentsDir } from '../state.js';
16
+ import { getCacheDir } from '../state.js';
17
17
  const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
18
18
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
19
- const CLOUD_CACHE_DIR = path.join(getAgentsDir(), 'cache', 'cloud-runs');
19
+ const CLOUD_CACHE_DIR = path.join(getCacheDir(), 'cloud-runs');
20
20
  function readToken() {
21
21
  if (!fs.existsSync(USER_YAML)) {
22
22
  throw new Error('Not logged in to Rush. Run `rush login` first.');
@@ -9,9 +9,9 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import Database from '../sqlite.js';
12
- import { getUserAgentsDir } from '../state.js';
13
- const SESSIONS_DIR = path.join(getUserAgentsDir(), 'sessions');
14
- const DB_PATH = path.join(SESSIONS_DIR, 'sessions.db');
12
+ import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
+ const SESSIONS_DIR = getSessionsDir();
14
+ const DB_PATH = getSessionsDbPath();
15
15
  /** Current schema version; bumped when migrations are added. */
16
16
  const SCHEMA_VERSION = 5;
17
17
  /**
@@ -151,6 +151,11 @@ export function getDB() {
151
151
  db.pragma('journal_mode = WAL');
152
152
  db.pragma('synchronous = NORMAL');
153
153
  db.pragma('temp_store = MEMORY');
154
+ // Wait up to 10s instead of failing immediately on SQLITE_BUSY. Multiple
155
+ // agents (CLIs, indexers, hooks) all open this DB concurrently; without a
156
+ // busy timeout, parallel writers throw "database is locked" the moment one
157
+ // holds the write lock. 10s is well above any realistic transaction here.
158
+ db.pragma('busy_timeout = 10000');
154
159
  db.exec(SCHEMA);
155
160
  const current = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
156
161
  const currentVersion = current ? parseInt(current.value, 10) : 0;