@phnx-labs/agents-cli 1.14.7 → 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 (105) hide show
  1. package/CHANGELOG.md +78 -39
  2. package/README.md +74 -7
  3. package/dist/commands/alias.js +2 -2
  4. package/dist/commands/beta.js +6 -1
  5. package/dist/commands/browser-picker.d.ts +21 -0
  6. package/dist/commands/browser-picker.js +114 -0
  7. package/dist/commands/browser.js +546 -75
  8. package/dist/commands/commands.js +72 -22
  9. package/dist/commands/daemon.js +2 -2
  10. package/dist/commands/exec.js +9 -2
  11. package/dist/commands/fork.js +2 -2
  12. package/dist/commands/hooks.js +71 -26
  13. package/dist/commands/mcp.js +85 -43
  14. package/dist/commands/plugins.js +48 -15
  15. package/dist/commands/prune.d.ts +0 -20
  16. package/dist/commands/prune.js +291 -16
  17. package/dist/commands/pull.js +3 -3
  18. package/dist/commands/repo.js +1 -1
  19. package/dist/commands/routines.js +2 -2
  20. package/dist/commands/secrets.js +37 -1
  21. package/dist/commands/sessions.js +62 -19
  22. package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
  23. package/dist/commands/{init.js → setup.js} +32 -21
  24. package/dist/commands/skills.js +60 -19
  25. package/dist/commands/subagents.js +41 -13
  26. package/dist/commands/teams.js +2 -3
  27. package/dist/commands/usage.js +6 -0
  28. package/dist/commands/utils.d.ts +16 -0
  29. package/dist/commands/utils.js +32 -0
  30. package/dist/commands/versions.js +8 -6
  31. package/dist/commands/view.js +61 -16
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +17 -20
  34. package/dist/lib/agents.js +2 -2
  35. package/dist/lib/auto-pull-worker.js +2 -3
  36. package/dist/lib/auto-pull.js +2 -2
  37. package/dist/lib/browser/cdp.d.ts +7 -1
  38. package/dist/lib/browser/cdp.js +29 -1
  39. package/dist/lib/browser/chrome.js +6 -3
  40. package/dist/lib/browser/devices.d.ts +4 -0
  41. package/dist/lib/browser/devices.js +27 -0
  42. package/dist/lib/browser/drivers/local.js +9 -4
  43. package/dist/lib/browser/drivers/ssh.d.ts +1 -0
  44. package/dist/lib/browser/drivers/ssh.js +32 -4
  45. package/dist/lib/browser/ipc.js +145 -23
  46. package/dist/lib/browser/profiles.d.ts +5 -2
  47. package/dist/lib/browser/profiles.js +77 -37
  48. package/dist/lib/browser/service.d.ts +84 -13
  49. package/dist/lib/browser/service.js +806 -122
  50. package/dist/lib/browser/types.d.ts +81 -3
  51. package/dist/lib/browser/types.js +16 -0
  52. package/dist/lib/cloud/rush.js +2 -2
  53. package/dist/lib/cloud/store.js +2 -2
  54. package/dist/lib/commands.d.ts +1 -0
  55. package/dist/lib/commands.js +6 -2
  56. package/dist/lib/daemon.js +6 -7
  57. package/dist/lib/doctor-diff.js +4 -4
  58. package/dist/lib/events.d.ts +94 -1
  59. package/dist/lib/events.js +264 -6
  60. package/dist/lib/exec.js +16 -10
  61. package/dist/lib/hooks.d.ts +11 -7
  62. package/dist/lib/hooks.js +125 -49
  63. package/dist/lib/migrate.d.ts +1 -1
  64. package/dist/lib/migrate.js +1178 -21
  65. package/dist/lib/models.js +2 -2
  66. package/dist/lib/permissions.d.ts +14 -11
  67. package/dist/lib/permissions.js +46 -42
  68. package/dist/lib/plugins.d.ts +30 -1
  69. package/dist/lib/plugins.js +75 -3
  70. package/dist/lib/pty-server.js +9 -10
  71. package/dist/lib/resources/hooks.d.ts +5 -1
  72. package/dist/lib/resources/hooks.js +21 -4
  73. package/dist/lib/rotate.js +3 -4
  74. package/dist/lib/routines.d.ts +15 -0
  75. package/dist/lib/routines.js +68 -0
  76. package/dist/lib/runner.js +9 -5
  77. package/dist/lib/secrets/index.d.ts +14 -11
  78. package/dist/lib/secrets/index.js +49 -21
  79. package/dist/lib/secrets/linux.d.ts +27 -0
  80. package/dist/lib/secrets/linux.js +161 -0
  81. package/dist/lib/session/active.d.ts +3 -0
  82. package/dist/lib/session/active.js +92 -6
  83. package/dist/lib/session/cloud.js +2 -2
  84. package/dist/lib/session/db.d.ts +4 -0
  85. package/dist/lib/session/db.js +34 -3
  86. package/dist/lib/session/discover.js +30 -15
  87. package/dist/lib/session/team-filter.js +2 -2
  88. package/dist/lib/shims.d.ts +2 -2
  89. package/dist/lib/shims.js +6 -6
  90. package/dist/lib/skills.js +6 -2
  91. package/dist/lib/state.d.ts +86 -14
  92. package/dist/lib/state.js +150 -23
  93. package/dist/lib/subagents.d.ts +28 -0
  94. package/dist/lib/subagents.js +98 -1
  95. package/dist/lib/sync-manifest.d.ts +1 -1
  96. package/dist/lib/sync-manifest.js +3 -3
  97. package/dist/lib/teams/persistence.js +15 -5
  98. package/dist/lib/teams/registry.js +2 -2
  99. package/dist/lib/types.d.ts +32 -3
  100. package/dist/lib/types.js +3 -3
  101. package/dist/lib/usage.d.ts +1 -1
  102. package/dist/lib/usage.js +15 -48
  103. package/dist/lib/versions.js +31 -21
  104. package/package.json +1 -1
  105. package/scripts/postinstall.js +37 -9
@@ -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.
@@ -72,21 +72,24 @@ export declare function getActivePermissionSetName(): string | null;
72
72
  export declare function buildPermissionsFromGroups(groupNames: string[]): PermissionSet;
73
73
  /**
74
74
  * List installed permission sets from central storage.
75
+ * User dir takes precedence; system entries are surfaced when user has no
76
+ * same-named override.
75
77
  */
76
78
  export declare function listInstalledPermissions(): InstalledPermission[];
77
79
  /**
78
- * Get a specific permission set by name.
80
+ * Get a specific permission set by name. Searches user dir first, then system.
79
81
  */
80
82
  export declare function getPermissionSet(name: string): InstalledPermission | null;
81
83
  /**
82
- * Install a permission set to central storage.
84
+ * Install a permission set to user-level central storage.
83
85
  */
84
86
  export declare function installPermissionSet(sourcePath: string, name: string): {
85
87
  success: boolean;
86
88
  error?: string;
87
89
  };
88
90
  /**
89
- * Remove a permission set from central storage.
91
+ * Remove a permission set from user-level central storage. System-shipped
92
+ * sets are intentionally not deletable from user commands.
90
93
  */
91
94
  export declare function removePermissionSet(name: string): {
92
95
  success: boolean;
@@ -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
  /**
@@ -259,55 +259,58 @@ export function buildPermissionsFromGroups(groupNames) {
259
259
  }
260
260
  /**
261
261
  * List installed permission sets from central storage.
262
+ * User dir takes precedence; system entries are surfaced when user has no
263
+ * same-named override.
262
264
  */
263
265
  export function listInstalledPermissions() {
264
266
  ensureAgentsDir();
265
- const dir = getPermissionsDir();
266
- if (!fs.existsSync(dir)) {
267
- return [];
268
- }
267
+ const seen = new Set();
269
268
  const results = [];
270
- try {
271
- const entries = fs.readdirSync(dir, { withFileTypes: true });
272
- for (const entry of entries) {
273
- if (!entry.isFile())
274
- continue;
275
- if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml'))
276
- continue;
277
- const filePath = path.join(dir, entry.name);
278
- const set = parsePermissionSet(filePath);
279
- if (set) {
280
- results.push({
281
- name: set.name,
282
- path: filePath,
283
- set,
284
- });
269
+ for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
270
+ if (!fs.existsSync(dir))
271
+ continue;
272
+ try {
273
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
274
+ for (const entry of entries) {
275
+ if (!entry.isFile())
276
+ continue;
277
+ if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml'))
278
+ continue;
279
+ const filePath = path.join(dir, entry.name);
280
+ const set = parsePermissionSet(filePath);
281
+ if (!set)
282
+ continue;
283
+ if (seen.has(set.name))
284
+ continue;
285
+ seen.add(set.name);
286
+ results.push({ name: set.name, path: filePath, set });
285
287
  }
286
288
  }
287
- }
288
- catch {
289
- // Ignore errors
289
+ catch {
290
+ // Skip inaccessible directory
291
+ }
290
292
  }
291
293
  return results;
292
294
  }
293
295
  /**
294
- * Get a specific permission set by name.
296
+ * Get a specific permission set by name. Searches user dir first, then system.
295
297
  */
296
298
  export function getPermissionSet(name) {
297
- const dir = getPermissionsDir();
298
- for (const ext of ['.yml', '.yaml']) {
299
- const filePath = safeJoin(dir, name + ext);
300
- if (fs.existsSync(filePath)) {
301
- const set = parsePermissionSet(filePath);
302
- if (set) {
303
- return { name: set.name, path: filePath, set };
299
+ for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
300
+ for (const ext of ['.yml', '.yaml']) {
301
+ const filePath = safeJoin(dir, name + ext);
302
+ if (fs.existsSync(filePath)) {
303
+ const set = parsePermissionSet(filePath);
304
+ if (set) {
305
+ return { name: set.name, path: filePath, set };
306
+ }
304
307
  }
305
308
  }
306
309
  }
307
310
  return null;
308
311
  }
309
312
  /**
310
- * Install a permission set to central storage.
313
+ * Install a permission set to user-level central storage.
311
314
  */
312
315
  export function installPermissionSet(sourcePath, name) {
313
316
  ensurePermissionsDir();
@@ -315,7 +318,7 @@ export function installPermissionSet(sourcePath, name) {
315
318
  if (!set) {
316
319
  return { success: false, error: 'Invalid permission file' };
317
320
  }
318
- const targetPath = safeJoin(getPermissionsDir(), name + '.yml');
321
+ const targetPath = safeJoin(getUserPermissionsDir(), name + '.yml');
319
322
  try {
320
323
  fs.copyFileSync(sourcePath, targetPath);
321
324
  return { success: true };
@@ -325,10 +328,11 @@ export function installPermissionSet(sourcePath, name) {
325
328
  }
326
329
  }
327
330
  /**
328
- * Remove a permission set from central storage.
331
+ * Remove a permission set from user-level central storage. System-shipped
332
+ * sets are intentionally not deletable from user commands.
329
333
  */
330
334
  export function removePermissionSet(name) {
331
- const dir = getPermissionsDir();
335
+ const dir = getUserPermissionsDir();
332
336
  for (const ext of ['.yml', '.yaml']) {
333
337
  const filePath = safeJoin(dir, name + ext);
334
338
  if (fs.existsSync(filePath)) {
@@ -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)) {
@@ -96,3 +96,18 @@ export declare function installJobFromSource(sourcePath: string, name: string):
96
96
  success: boolean;
97
97
  error?: string;
98
98
  };
99
+ /** List all job names that have run directories. */
100
+ export declare function listJobsWithRuns(): string[];
101
+ /** Count total runs across all jobs. */
102
+ export declare function countAllRuns(): number;
103
+ /** Preview runs that would be pruned (keeping only the most recent `keep` per job). */
104
+ export declare function previewRunsPrune(keep: number): Array<{
105
+ jobName: string;
106
+ runId: string;
107
+ startedAt: string;
108
+ }>;
109
+ /** Delete old runs, keeping only the most recent `keep` per job. Returns bytes freed and run count. */
110
+ export declare function pruneRuns(keep: number): {
111
+ deleted: number;
112
+ bytesFreed: number;
113
+ };
@@ -350,3 +350,71 @@ export function installJobFromSource(sourcePath, name) {
350
350
  return { success: false, error: err.message };
351
351
  }
352
352
  }
353
+ /** List all job names that have run directories. */
354
+ export function listJobsWithRuns() {
355
+ const runsDir = getRunsDir();
356
+ if (!fs.existsSync(runsDir))
357
+ return [];
358
+ return fs.readdirSync(runsDir, { withFileTypes: true })
359
+ .filter((e) => e.isDirectory())
360
+ .map((e) => e.name);
361
+ }
362
+ /** Count total runs across all jobs. */
363
+ export function countAllRuns() {
364
+ let total = 0;
365
+ for (const jobName of listJobsWithRuns()) {
366
+ total += listRuns(jobName).length;
367
+ }
368
+ return total;
369
+ }
370
+ /** Preview runs that would be pruned (keeping only the most recent `keep` per job). */
371
+ export function previewRunsPrune(keep) {
372
+ const toPrune = [];
373
+ for (const jobName of listJobsWithRuns()) {
374
+ const runs = listRuns(jobName);
375
+ if (runs.length > keep) {
376
+ const toRemove = runs.slice(0, runs.length - keep);
377
+ for (const run of toRemove) {
378
+ toPrune.push({ jobName, runId: run.runId, startedAt: run.startedAt });
379
+ }
380
+ }
381
+ }
382
+ return toPrune;
383
+ }
384
+ /** Delete old runs, keeping only the most recent `keep` per job. Returns bytes freed and run count. */
385
+ export function pruneRuns(keep) {
386
+ let deleted = 0;
387
+ let bytesFreed = 0;
388
+ for (const jobName of listJobsWithRuns()) {
389
+ const runs = listRuns(jobName);
390
+ if (runs.length <= keep)
391
+ continue;
392
+ const toRemove = runs.slice(0, runs.length - keep);
393
+ for (const run of toRemove) {
394
+ const runDir = getRunDir(jobName, run.runId);
395
+ bytesFreed += getDirSize(runDir);
396
+ fs.rmSync(runDir, { recursive: true, force: true });
397
+ deleted++;
398
+ }
399
+ }
400
+ return { deleted, bytesFreed };
401
+ }
402
+ function getDirSize(dirPath) {
403
+ if (!fs.existsSync(dirPath))
404
+ return 0;
405
+ let size = 0;
406
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
407
+ for (const entry of entries) {
408
+ const fullPath = path.join(dirPath, entry.name);
409
+ if (entry.isDirectory()) {
410
+ size += getDirSize(fullPath);
411
+ }
412
+ else {
413
+ try {
414
+ size += fs.statSync(fullPath).size;
415
+ }
416
+ catch { /* ignore */ }
417
+ }
418
+ }
419
+ return size;
420
+ }
@@ -14,7 +14,7 @@ import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, } from './rout
14
14
  import { getRunsDir } from './state.js';
15
15
  import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
16
16
  import { resolveModel, buildReasoningFlags } from './models.js';
17
- import { emitStart, maybeRotate } from './events.js';
17
+ import { createTimer, maybeRotate, truncate } from './events.js';
18
18
  /** CLI command templates per agent, with {prompt} as a placeholder. */
19
19
  const AGENT_COMMANDS = {
20
20
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -109,11 +109,13 @@ function generateRunId() {
109
109
  /** Execute a job synchronously (waits for completion or timeout before resolving). */
110
110
  export async function executeJob(config) {
111
111
  maybeRotate();
112
- const done = emitStart('agent.run.start', {
112
+ const timer = createTimer('agent.run', {
113
113
  agent: config.agent,
114
114
  version: config.version,
115
115
  jobName: config.name,
116
116
  mode: config.mode,
117
+ prompt: truncate(config.prompt, 200),
118
+ schedule: config.schedule,
117
119
  });
118
120
  const resolvedPrompt = resolveJobPrompt(config);
119
121
  const cmd = buildJobCommand(config, resolvedPrompt);
@@ -146,6 +148,8 @@ export async function executeJob(config) {
146
148
  detached: true,
147
149
  env: spawnEnv,
148
150
  });
151
+ // Mark startup time (time from function call to process spawn)
152
+ timer.mark('startup');
149
153
  meta.pid = child.pid || null;
150
154
  writeRunMeta(meta);
151
155
  let settled = false;
@@ -168,7 +172,7 @@ export async function executeJob(config) {
168
172
  meta.status = 'timeout';
169
173
  meta.completedAt = new Date().toISOString();
170
174
  writeRunMeta(meta);
171
- done({ status: 'timeout', runId });
175
+ timer.end({ status: 'timeout', runId });
172
176
  const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
173
177
  resolve({ meta, reportPath });
174
178
  }, timeoutMs);
@@ -185,7 +189,7 @@ export async function executeJob(config) {
185
189
  meta.status = code === 0 ? 'completed' : 'failed';
186
190
  meta.completedAt = new Date().toISOString();
187
191
  writeRunMeta(meta);
188
- done({ status: meta.status, exitCode: code, runId });
192
+ timer.end({ status: meta.status, exitCode: code ?? undefined, runId });
189
193
  const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
190
194
  resolve({ meta, reportPath });
191
195
  });
@@ -201,7 +205,7 @@ export async function executeJob(config) {
201
205
  meta.status = 'failed';
202
206
  meta.completedAt = new Date().toISOString();
203
207
  writeRunMeta(meta);
204
- done({ status: 'failed', error: err.message, runId });
208
+ timer.end({ status: 'failed', error: err.message, runId });
205
209
  resolve({ meta, reportPath: null });
206
210
  });
207
211
  child.unref();