@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
@@ -16,12 +16,17 @@ export interface BrowserProfile {
16
16
  export interface ChromeOptions {
17
17
  headless?: boolean;
18
18
  args?: string[];
19
+ viewport?: {
20
+ width: number;
21
+ height: number;
22
+ };
19
23
  }
20
24
  export interface Task {
21
25
  id: string;
26
+ name: string;
22
27
  profile: string;
23
- windowTargetId?: string;
24
- tabIds: string[];
28
+ tabs: Record<string, string>;
29
+ currentTabId?: string;
25
30
  createdAt: number;
26
31
  pid: number;
27
32
  }
@@ -36,17 +41,39 @@ export interface ProfileStatus {
36
41
  running: boolean;
37
42
  port?: number;
38
43
  pid?: number;
44
+ /** The port declared in the profile's first endpoint, when it differs from the running port. */
45
+ configuredPort?: number;
39
46
  tasks: TaskStatus[];
40
47
  }
41
48
  export interface TaskStatus {
42
49
  id: string;
50
+ name: string;
43
51
  tabCount: number;
52
+ currentTabId?: string;
44
53
  createdAt: number;
54
+ endedAt?: number;
55
+ domains?: string[];
56
+ tabs?: Array<{
57
+ id: string;
58
+ url: string;
59
+ title?: string;
60
+ current?: boolean;
61
+ }>;
45
62
  }
46
- export type IPCAction = 'start' | 'stop' | 'status' | 'navigate' | 'tabs' | 'close' | 'evaluate' | 'screenshot' | 'refs' | 'click' | 'type' | 'press' | 'hover';
63
+ export interface HistoricalTask {
64
+ id: string;
65
+ name: string;
66
+ profile: string;
67
+ createdAt: number;
68
+ endedAt: number;
69
+ domains: string[];
70
+ tabCount: number;
71
+ }
72
+ export type IPCAction = 'start' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download';
47
73
  export interface IPCRequest {
48
74
  action: IPCAction;
49
75
  task?: string;
76
+ taskName?: string;
50
77
  profile?: string;
51
78
  url?: string;
52
79
  tabId?: string;
@@ -57,6 +84,20 @@ export interface IPCRequest {
57
84
  key?: string;
58
85
  interactive?: boolean;
59
86
  limit?: number;
87
+ width?: number;
88
+ height?: number;
89
+ deviceName?: string;
90
+ mobile?: boolean;
91
+ deviceScaleFactor?: number;
92
+ level?: 'log' | 'info' | 'warn' | 'error';
93
+ clear?: boolean;
94
+ filter?: string;
95
+ urlPattern?: string;
96
+ maxChars?: number;
97
+ waitType?: 'time' | 'selector' | 'url' | 'function' | 'load';
98
+ waitValue?: string | number;
99
+ timeout?: number;
100
+ downloadPath?: string;
60
101
  }
61
102
  export interface IPCResponse {
62
103
  ok: boolean;
@@ -66,10 +107,47 @@ export interface IPCResponse {
66
107
  windowTargetId?: string;
67
108
  tabs?: TabInfo[];
68
109
  profiles?: ProfileStatus[];
110
+ history?: HistoricalTask[];
69
111
  result?: unknown;
70
112
  path?: string;
71
113
  refs?: string;
114
+ logs?: ConsoleEntry[];
115
+ errors?: ErrorEntry[];
116
+ requests?: NetworkRequest[];
117
+ body?: string;
118
+ downloadPath?: string;
119
+ devices?: string[];
120
+ }
121
+ export interface ConsoleEntry {
122
+ level: 'log' | 'info' | 'warn' | 'error';
123
+ text: string;
124
+ timestamp: number;
125
+ url?: string;
126
+ line?: number;
127
+ }
128
+ export interface ErrorEntry {
129
+ message: string;
130
+ stack?: string;
131
+ timestamp: number;
132
+ url?: string;
133
+ line?: number;
134
+ }
135
+ export interface NetworkRequest {
136
+ id: string;
137
+ url: string;
138
+ method: string;
139
+ status?: number;
140
+ mimeType?: string;
141
+ timestamp: number;
142
+ }
143
+ export interface DeviceDescriptor {
144
+ width: number;
145
+ height: number;
146
+ deviceScaleFactor: number;
147
+ mobile: boolean;
72
148
  }
73
149
  export declare const TASK_ID_REGEX: RegExp;
74
150
  export declare function isValidTaskId(id: string): boolean;
75
151
  export declare function generateTaskId(): string;
152
+ export declare function generateShortId(): string;
153
+ export declare function generateFunName(): string;
@@ -5,3 +5,19 @@ export function isValidTaskId(id) {
5
5
  export function generateTaskId() {
6
6
  return crypto.randomUUID().slice(0, 8);
7
7
  }
8
+ export function generateShortId() {
9
+ return crypto.randomUUID().split('-')[0]; // 8 chars
10
+ }
11
+ const ADJECTIVES = [
12
+ 'swift', 'cosmic', 'jolly', 'quiet', 'bold', 'bright', 'calm', 'eager',
13
+ 'golden', 'happy', 'keen', 'lucky', 'noble', 'proud', 'quick', 'royal',
14
+ ];
15
+ const NOUNS = [
16
+ 'falcon', 'comet', 'tiger', 'nebula', 'phoenix', 'river', 'summit', 'wave',
17
+ 'aurora', 'breeze', 'crystal', 'dragon', 'ember', 'forest', 'glacier', 'harbor',
18
+ ];
19
+ export function generateFunName() {
20
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
21
+ const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
22
+ return `${adj}-${noun}`;
23
+ }
@@ -9,7 +9,7 @@ import * as path from 'path';
9
9
  import * as os from 'os';
10
10
  import * as crypto from 'crypto';
11
11
  import * as yaml from 'yaml';
12
- import { getUserAgentsDir } from '../state.js';
12
+ import { getCloudDir } from '../state.js';
13
13
  import { resolveDispatchRepos } from './types.js';
14
14
  import { parseSSE } from './stream.js';
15
15
  import { listInstalledVersions, getVersionHomePath } from '../versions.js';
@@ -20,7 +20,7 @@ const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
20
20
  // Persistent consent record for uploading Claude OAuth blobs to Rush Cloud.
21
21
  // Created on first explicit consent (env var or flag); subsequent dispatches
22
22
  // see it and proceed without re-prompting.
23
- const RUSH_CONSENT_PATH = path.join(getUserAgentsDir(), 'cloud', 'rush-consent.json');
23
+ const RUSH_CONSENT_PATH = path.join(getCloudDir(), 'rush-consent.json');
24
24
  const RUSH_CONSENT_ENV = 'AGENTS_RUSH_UPLOAD_TOKENS';
25
25
  function hasRushUploadConsent(opts) {
26
26
  if (process.env[RUSH_CONSENT_ENV] === '1')
@@ -8,8 +8,8 @@
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import Database from '../sqlite.js';
11
- import { getUserAgentsDir } from '../state.js';
12
- const CLOUD_DIR = path.join(getUserAgentsDir(), 'cloud');
11
+ import { getCloudDir } from '../state.js';
12
+ const CLOUD_DIR = getCloudDir();
13
13
  const DB_PATH = path.join(CLOUD_DIR, 'tasks.db');
14
14
  const SCHEMA = `
15
15
  CREATE TABLE IF NOT EXISTS tasks (
@@ -79,6 +79,7 @@ export declare function installCommandToVersion(agent: AgentId, version: string,
79
79
  };
80
80
  /**
81
81
  * Remove a single command from a specific version home.
82
+ * Soft-deletes to ~/.agents/.trash/commands/.
82
83
  */
83
84
  export declare function removeCommandFromVersion(agent: AgentId, version: string, commandName: string): {
84
85
  success: boolean;
@@ -11,7 +11,7 @@ import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { AGENTS, COMMANDS_CAPABLE_AGENTS, ensureCommandsDir } from './agents.js';
13
13
  import { markdownToToml } from './convert.js';
14
- import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir } from './state.js';
14
+ import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
15
15
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
16
16
  import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
17
17
  /** Parse command metadata (name, description) from YAML frontmatter or TOML headers. */
@@ -279,6 +279,7 @@ export function installCommandToVersion(agent, version, commandName, method = 'c
279
279
  }
280
280
  /**
281
281
  * Remove a single command from a specific version home.
282
+ * Soft-deletes to ~/.agents/.trash/commands/.
282
283
  */
283
284
  export function removeCommandFromVersion(agent, version, commandName) {
284
285
  const versionHome = getVersionHomePath(agent, version);
@@ -292,7 +293,10 @@ export function removeCommandFromVersion(agent, version, commandName) {
292
293
  return { success: true };
293
294
  }
294
295
  try {
295
- fs.unlinkSync(targetPath);
296
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
297
+ const trashDir = path.join(getTrashCommandsDir(), agent, version, commandName);
298
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
299
+ fs.renameSync(targetPath, path.join(trashDir, `${commandName}${ext}.${stamp}`));
296
300
  }
297
301
  catch (err) {
298
302
  return { success: false, error: err.message };
@@ -10,13 +10,12 @@ import { spawn, execSync, execFileSync } from 'child_process';
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
- import { getAgentsDir } from './state.js';
13
+ import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
14
  import { listJobs as listAllJobs } from './routines.js';
15
15
  import { JobScheduler } from './scheduler.js';
16
16
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
17
17
  import { BrowserService } from './browser/service.js';
18
18
  import { BrowserIPCServer } from './browser/ipc.js';
19
- const DAEMON_DIR = 'helpers/daemon';
20
19
  const PID_FILE = 'daemon.pid';
21
20
  const LOCK_FILE = 'daemon.lock';
22
21
  const LOG_FILE = 'logs.jsonl';
@@ -25,7 +24,7 @@ const LOG_ROTATE_COUNT = 3;
25
24
  const PLIST_NAME = 'com.phnx-labs.agents-daemon';
26
25
  const SYSTEMD_UNIT = 'agents-daemon.service';
27
26
  function getDaemonDir() {
28
- const dir = path.join(getAgentsDir(), DAEMON_DIR);
27
+ const dir = getDaemonDirRoot();
29
28
  fs.mkdirSync(dir, { recursive: true });
30
29
  return dir;
31
30
  }
@@ -22,7 +22,7 @@
22
22
  import * as fs from 'fs';
23
23
  import * as path from 'path';
24
24
  import { AGENTS } from './agents.js';
25
- import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getPromptcutsPath, } from './state.js';
25
+ import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, } from './state.js';
26
26
  import { getAvailableResources, getActuallySyncedResources, getVersionHomePath, } from './versions.js';
27
27
  import { markdownToToml } from './convert.js';
28
28
  import { resolveImports, supportsRulesImports } from './rules/compile.js';
@@ -426,10 +426,10 @@ function diffPresenceOnly(kind, available, synced) {
426
426
  return rows.sort((a, b) => a.name.localeCompare(b.name));
427
427
  }
428
428
  function diffPromptcuts() {
429
- const exists = fs.existsSync(getPromptcutsPath());
430
- if (!exists)
429
+ const sourcePath = getEffectivePromptcutsPath();
430
+ if (!fs.existsSync(sourcePath))
431
431
  return [];
432
- return [{ kind: 'promptcuts', name: 'promptcuts.yaml', status: 'ok', sourcePath: getPromptcutsPath() }];
432
+ return [{ kind: 'promptcuts', name: 'promptcuts.yaml', status: 'ok', sourcePath }];
433
433
  }
434
434
  export function diffVersionResources(agent, version, options = {}) {
435
435
  const cwd = options.cwd ?? process.cwd();
@@ -15,8 +15,8 @@ import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
  import * as os from 'os';
17
17
  // ─── Constants ────────────────────────────────────────────────────────────────
18
- const USER_AGENTS_DIR = path.join(os.homedir(), '.agents');
19
- const LOGS_DIR = path.join(USER_AGENTS_DIR, 'logs');
18
+ // Logs live under the cache bucket — they're regenerable telemetry.
19
+ const LOGS_DIR = path.join(os.homedir(), '.agents', '.cache', 'logs');
20
20
  /** Default retention period in days. */
21
21
  const DEFAULT_RETENTION_DAYS = 30;
22
22
  /** Default max length for truncated strings. */
@@ -66,6 +66,7 @@ export declare function installHookToVersion(agent: AgentId, version: string, ho
66
66
  };
67
67
  /**
68
68
  * Remove a single hook (script + data file) from a specific version home.
69
+ * Soft-deletes to ~/.agents/.trash/hooks/.
69
70
  */
70
71
  export declare function removeHookFromVersion(agent: AgentId, version: string, hookName: string): {
71
72
  success: boolean;
@@ -113,10 +114,11 @@ export declare function installHooksCentrally(source: string): Promise<{
113
114
  */
114
115
  export declare function listCentralHooks(): HookEntry[];
115
116
  /**
116
- * Parse hooks.yaml manifests. Reads BOTH system (~/.agents-system/hooks.yaml)
117
- * and user (~/.agents/hooks.yaml), merging with user-wins-on-key-collision
118
- * precedence. A user entry with `enabled: false` disables the system-shipped
119
- * hook of the same name without forking the system file.
117
+ * Parse hook manifests. Reads system hooks from ~/.agents-system/hooks.yaml
118
+ * (npm-shipped defaults) and user hooks from the `hooks:` section of
119
+ * ~/.agents/agents.yaml. Merges with user-wins-on-key-collision precedence.
120
+ * A user entry with `enabled: false` disables the system-shipped hook of
121
+ * the same name without forking the system file.
120
122
  *
121
123
  * Hooks marked `enabled: false` are dropped from the returned map.
122
124
  */
@@ -124,10 +126,12 @@ export declare function parseHookManifest(): Record<string, ManifestHook>;
124
126
  /**
125
127
  * Register hooks as lifecycle events in an agent's config.
126
128
  * Reads hooks.yaml manifest, merges into the agent's config file(s).
127
- * Only manages hooks whose command paths are under ~/.agents/hooks/.
128
- * Does not remove user-added hooks.
129
+ * Only manages hooks whose command paths are under ~/.agents/hooks/ or
130
+ * ~/.agents-system/hooks/. Does not remove user-added hooks.
129
131
  *
130
- * @param agentsDirOverride - Override the agents dir (used in tests to inject a temp path).
132
+ * @param agentsDirOverride - When provided, treats this single dir as the
133
+ * only managed hook root. Used by tests to inject a temp path. In normal
134
+ * operation, both user and system roots are consulted with user precedence.
131
135
  */
132
136
  export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string): {
133
137
  registered: string[];
package/dist/lib/hooks.js CHANGED
@@ -14,8 +14,42 @@ import * as TOML from 'smol-toml';
14
14
  import { AGENTS, HOOKS_CAPABLE_AGENTS } from './agents.js';
15
15
  import { supports, explainSkip } from './capabilities.js';
16
16
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
17
- import { getAgentsDir, getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getProjectAgentsDir } from './state.js';
17
+ import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir } from './state.js';
18
18
  function getCentralHooksDir() { return getUserHooksDir(); }
19
+ /**
20
+ * Resolve a hook script's absolute path by checking the user dir first
21
+ * (where `installHooksCentrally` lands new files) and falling back to the
22
+ * system dir (where npm-shipped defaults live). Returns null if neither
23
+ * exists. Mirrors the precedence used by `listCentralHooks`.
24
+ */
25
+ function resolveHookScriptPath(script) {
26
+ for (const root of [getUserAgentsDir(), getSystemAgentsDir()]) {
27
+ const candidate = path.join(root, 'hooks', script);
28
+ if (fs.existsSync(candidate))
29
+ return candidate;
30
+ }
31
+ return null;
32
+ }
33
+ /**
34
+ * Prefixes used for stale-entry cleanup in agent settings files. A registered
35
+ * hook command is considered "managed by us" if it lives under either
36
+ * `~/.agents/hooks/` (user) or `~/.agents-system/hooks/` (system). Cleanup
37
+ * filters use this list so leftover entries from either dir get garbage
38
+ * collected on rewrite.
39
+ */
40
+ function getManagedHookPrefixes() {
41
+ return [
42
+ path.join(getUserAgentsDir(), 'hooks') + path.sep,
43
+ path.join(getSystemAgentsDir(), 'hooks') + path.sep,
44
+ ];
45
+ }
46
+ function isManagedHookCommand(command, prefixes) {
47
+ for (const prefix of prefixes) {
48
+ if (command.startsWith(prefix))
49
+ return true;
50
+ }
51
+ return false;
52
+ }
19
53
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
20
54
  const SCRIPT_EXTENSIONS = new Set([
21
55
  '.sh',
@@ -366,10 +400,32 @@ export function installHookToVersion(agent, version, hookName) {
366
400
  }
367
401
  /**
368
402
  * Remove a single hook (script + data file) from a specific version home.
403
+ * Soft-deletes to ~/.agents/.trash/hooks/.
369
404
  */
370
405
  export function removeHookFromVersion(agent, version, hookName) {
371
406
  try {
372
- removeHookFiles(getVersionHooksDir(agent, version), hookName);
407
+ const hooksDir = getVersionHooksDir(agent, version);
408
+ if (!fs.existsSync(hooksDir))
409
+ return { success: true };
410
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
411
+ const trashDir = path.join(getTrashHooksDir(), agent, version, hookName, stamp);
412
+ let moved = false;
413
+ const files = fs.readdirSync(hooksDir);
414
+ for (const file of files) {
415
+ const ext = path.extname(file);
416
+ const base = path.basename(file, ext);
417
+ if (base === hookName) {
418
+ const fullPath = path.join(hooksDir, file);
419
+ const stat = fs.statSync(fullPath);
420
+ if (stat.isFile()) {
421
+ if (!moved) {
422
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
423
+ moved = true;
424
+ }
425
+ fs.renameSync(fullPath, path.join(trashDir, file));
426
+ }
427
+ }
428
+ }
373
429
  }
374
430
  catch (err) {
375
431
  return { success: false, error: err.message };
@@ -502,32 +558,37 @@ export function listCentralHooks() {
502
558
  return results;
503
559
  }
504
560
  /**
505
- * Parse hooks.yaml manifests. Reads BOTH system (~/.agents-system/hooks.yaml)
506
- * and user (~/.agents/hooks.yaml), merging with user-wins-on-key-collision
507
- * precedence. A user entry with `enabled: false` disables the system-shipped
508
- * hook of the same name without forking the system file.
561
+ * Parse hook manifests. Reads system hooks from ~/.agents-system/hooks.yaml
562
+ * (npm-shipped defaults) and user hooks from the `hooks:` section of
563
+ * ~/.agents/agents.yaml. Merges with user-wins-on-key-collision precedence.
564
+ * A user entry with `enabled: false` disables the system-shipped hook of
565
+ * the same name without forking the system file.
509
566
  *
510
567
  * Hooks marked `enabled: false` are dropped from the returned map.
511
568
  */
512
569
  export function parseHookManifest() {
513
570
  const merged = {};
514
- // System first (lower precedence), user second (overrides).
515
- for (const dir of [getAgentsDir(), getUserAgentsDir()]) {
516
- const manifestPath = path.join(dir, 'hooks.yaml');
517
- if (!fs.existsSync(manifestPath))
518
- continue;
571
+ // System layer: hooks: section of agents.yaml (npm-shipped, separate repo).
572
+ const systemPath = path.join(getSystemAgentsDir(), 'agents.yaml');
573
+ if (fs.existsSync(systemPath)) {
519
574
  try {
520
- const content = fs.readFileSync(manifestPath, 'utf-8');
521
- const parsed = yaml.parse(content);
522
- if (!parsed)
523
- continue;
524
- for (const [name, def] of Object.entries(parsed)) {
525
- merged[name] = def;
526
- }
575
+ const meta = yaml.parse(fs.readFileSync(systemPath, 'utf-8'));
576
+ if (meta?.hooks)
577
+ for (const [name, def] of Object.entries(meta.hooks))
578
+ merged[name] = def;
527
579
  }
528
- catch {
529
- /* skip unreadable manifest, keep going */
580
+ catch { /* skip unreadable manifest */ }
581
+ }
582
+ // User layer: hooks: section of agents.yaml.
583
+ const userMetaPath = path.join(getUserAgentsDir(), 'agents.yaml');
584
+ if (fs.existsSync(userMetaPath)) {
585
+ try {
586
+ const meta = yaml.parse(fs.readFileSync(userMetaPath, 'utf-8'));
587
+ if (meta?.hooks)
588
+ for (const [name, def] of Object.entries(meta.hooks))
589
+ merged[name] = def;
530
590
  }
591
+ catch { /* skip unreadable meta */ }
531
592
  }
532
593
  // Strip disabled hooks so they never reach the registrar.
533
594
  for (const [name, def] of Object.entries(merged)) {
@@ -542,25 +603,37 @@ const CODEX_MATCHER_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart
542
603
  /**
543
604
  * Register hooks as lifecycle events in an agent's config.
544
605
  * Reads hooks.yaml manifest, merges into the agent's config file(s).
545
- * Only manages hooks whose command paths are under ~/.agents/hooks/.
546
- * Does not remove user-added hooks.
606
+ * Only manages hooks whose command paths are under ~/.agents/hooks/ or
607
+ * ~/.agents-system/hooks/. Does not remove user-added hooks.
547
608
  *
548
- * @param agentsDirOverride - Override the agents dir (used in tests to inject a temp path).
609
+ * @param agentsDirOverride - When provided, treats this single dir as the
610
+ * only managed hook root. Used by tests to inject a temp path. In normal
611
+ * operation, both user and system roots are consulted with user precedence.
549
612
  */
550
613
  export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride) {
551
614
  const manifest = hookManifest || parseHookManifest();
552
615
  if (Object.keys(manifest).length === 0) {
553
616
  return { registered: [], errors: [] };
554
617
  }
555
- const agentsDir = agentsDirOverride ?? getAgentsDir();
618
+ const overrideRoots = agentsDirOverride ? [agentsDirOverride] : null;
619
+ const resolveScript = (script) => {
620
+ if (overrideRoots) {
621
+ const candidate = path.join(overrideRoots[0], 'hooks', script);
622
+ return fs.existsSync(candidate) ? candidate : null;
623
+ }
624
+ return resolveHookScriptPath(script);
625
+ };
626
+ const managedPrefixes = overrideRoots
627
+ ? [path.join(overrideRoots[0], 'hooks') + path.sep]
628
+ : getManagedHookPrefixes();
556
629
  if (agentId === 'claude') {
557
- return registerHooksForClaude(versionHome, manifest, agentsDir);
630
+ return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes);
558
631
  }
559
632
  if (agentId === 'codex') {
560
- return registerHooksForCodex(versionHome, manifest, agentsDir);
633
+ return registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes);
561
634
  }
562
635
  if (agentId === 'gemini') {
563
- return registerHooksForGemini(versionHome, manifest, agentsDir);
636
+ return registerHooksForGemini(versionHome, manifest, resolveScript, managedPrefixes);
564
637
  }
565
638
  return { registered: [], errors: [] };
566
639
  }
@@ -574,7 +647,7 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
574
647
  const GEMINI_EVENT_MAP = {
575
648
  UserPromptSubmit: 'BeforeAgent',
576
649
  };
577
- function registerHooksForClaude(versionHome, manifest, agentsDir) {
650
+ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes) {
578
651
  const registered = [];
579
652
  const errors = [];
580
653
  const configDir = path.join(versionHome, '.claude');
@@ -595,14 +668,15 @@ function registerHooksForClaude(versionHome, manifest, agentsDir) {
595
668
  const hooks = config.hooks;
596
669
  // Build set of all command paths the current manifest will register.
597
670
  // Used to garbage-collect stale entries left behind after hook renames.
598
- const managedHooksPrefix = path.join(agentsDir, 'hooks') + path.sep;
599
671
  const currentManifestPaths = new Set();
600
672
  for (const hookDef of Object.values(manifest)) {
601
673
  if (!hookDef.events || hookDef.events.length === 0)
602
674
  continue;
603
- currentManifestPaths.add(path.join(agentsDir, 'hooks', hookDef.script));
675
+ const resolved = resolveScript(hookDef.script);
676
+ if (resolved)
677
+ currentManifestPaths.add(resolved);
604
678
  }
605
- // Remove stale entries: any hook command under ~/.agents/hooks/ that isn't
679
+ // Remove stale entries: any hook command under a managed root that isn't
606
680
  // in the current manifest is a leftover from a renamed/deleted hook script.
607
681
  for (const eventEntries of Object.values(hooks)) {
608
682
  if (!Array.isArray(eventEntries))
@@ -610,7 +684,7 @@ function registerHooksForClaude(versionHome, manifest, agentsDir) {
610
684
  for (const group of eventEntries) {
611
685
  if (!group.hooks)
612
686
  continue;
613
- group.hooks = group.hooks.filter((h) => !h.command.startsWith(managedHooksPrefix) || currentManifestPaths.has(h.command));
687
+ group.hooks = group.hooks.filter((h) => !isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command));
614
688
  }
615
689
  }
616
690
  // Remove empty matcher groups left after cleanup
@@ -622,9 +696,9 @@ function registerHooksForClaude(versionHome, manifest, agentsDir) {
622
696
  for (const [name, hookDef] of Object.entries(manifest)) {
623
697
  if (!hookDef.events || hookDef.events.length === 0)
624
698
  continue;
625
- const commandPath = path.join(agentsDir, 'hooks', hookDef.script);
626
- if (!fs.existsSync(commandPath)) {
627
- errors.push(`${name}: script not found at ${commandPath}`);
699
+ const commandPath = resolveScript(hookDef.script);
700
+ if (!commandPath) {
701
+ errors.push(`${name}: script not found in user or system hooks dir`);
628
702
  continue;
629
703
  }
630
704
  for (const event of hookDef.events) {
@@ -662,7 +736,7 @@ function registerHooksForClaude(versionHome, manifest, agentsDir) {
662
736
  }
663
737
  return { registered, errors };
664
738
  }
665
- function registerHooksForCodex(versionHome, manifest, agentsDir) {
739
+ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes) {
666
740
  const registered = [];
667
741
  const errors = [];
668
742
  const configDir = path.join(versionHome, '.codex');
@@ -687,19 +761,20 @@ function registerHooksForCodex(versionHome, manifest, agentsDir) {
687
761
  }
688
762
  }
689
763
  // Build set of current manifest command paths for codex to GC stale entries
690
- const managedHooksPrefix = path.join(agentsDir, 'hooks') + path.sep;
691
764
  const currentManifestPaths = new Set();
692
765
  for (const hookDef of Object.values(manifest)) {
693
766
  if (!hookDef.events || hookDef.events.length === 0)
694
767
  continue;
695
- currentManifestPaths.add(path.join(agentsDir, 'hooks', hookDef.script));
768
+ const resolved = resolveScript(hookDef.script);
769
+ if (resolved)
770
+ currentManifestPaths.add(resolved);
696
771
  }
697
772
  // Remove stale entries from all event groups
698
773
  for (const eventGroups of Object.values(hooksFile.hooks)) {
699
774
  for (const group of eventGroups) {
700
775
  if (!group.hooks)
701
776
  continue;
702
- group.hooks = group.hooks.filter((h) => !h.command.startsWith(managedHooksPrefix) || currentManifestPaths.has(h.command));
777
+ group.hooks = group.hooks.filter((h) => !isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command));
703
778
  }
704
779
  }
705
780
  for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
@@ -708,9 +783,9 @@ function registerHooksForCodex(versionHome, manifest, agentsDir) {
708
783
  for (const [name, hookDef] of Object.entries(manifest)) {
709
784
  if (!hookDef.events || hookDef.events.length === 0)
710
785
  continue;
711
- const commandPath = path.join(agentsDir, 'hooks', hookDef.script);
712
- if (!fs.existsSync(commandPath)) {
713
- errors.push(`${name}: script not found at ${commandPath}`);
786
+ const commandPath = resolveScript(hookDef.script);
787
+ if (!commandPath) {
788
+ errors.push(`${name}: script not found in user or system hooks dir`);
714
789
  continue;
715
790
  }
716
791
  const timeout = hookDef.timeout || 600;
@@ -784,7 +859,7 @@ function registerHooksForCodex(versionHome, manifest, agentsDir) {
784
859
  }
785
860
  return { registered, errors };
786
861
  }
787
- function registerHooksForGemini(versionHome, manifest, agentsDir) {
862
+ function registerHooksForGemini(versionHome, manifest, resolveScript, managedPrefixes) {
788
863
  const registered = [];
789
864
  const errors = [];
790
865
  const settingsPath = path.join(versionHome, '.gemini', 'settings.json');
@@ -795,12 +870,13 @@ function registerHooksForGemini(versionHome, manifest, agentsDir) {
795
870
  config.hooks = {};
796
871
  }
797
872
  const hooks = config.hooks;
798
- const managedHooksPrefix = path.join(agentsDir, 'hooks') + path.sep;
799
873
  const currentManifestPaths = new Set();
800
874
  for (const hookDef of Object.values(manifest)) {
801
875
  if (!hookDef.events || hookDef.events.length === 0)
802
876
  continue;
803
- currentManifestPaths.add(path.join(agentsDir, 'hooks', hookDef.script));
877
+ const resolved = resolveScript(hookDef.script);
878
+ if (resolved)
879
+ currentManifestPaths.add(resolved);
804
880
  }
805
881
  for (const eventEntries of Object.values(hooks)) {
806
882
  if (!Array.isArray(eventEntries))
@@ -808,7 +884,7 @@ function registerHooksForGemini(versionHome, manifest, agentsDir) {
808
884
  for (const group of eventEntries) {
809
885
  if (!group.hooks)
810
886
  continue;
811
- group.hooks = group.hooks.filter((h) => !h.command.startsWith(managedHooksPrefix) || currentManifestPaths.has(h.command));
887
+ group.hooks = group.hooks.filter((h) => !isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command));
812
888
  }
813
889
  }
814
890
  for (const [event, eventEntries] of Object.entries(hooks)) {
@@ -819,9 +895,9 @@ function registerHooksForGemini(versionHome, manifest, agentsDir) {
819
895
  for (const [name, hookDef] of Object.entries(manifest)) {
820
896
  if (!hookDef.events || hookDef.events.length === 0)
821
897
  continue;
822
- const commandPath = path.join(agentsDir, 'hooks', hookDef.script);
823
- if (!fs.existsSync(commandPath)) {
824
- errors.push(`${name}: script not found at ${commandPath}`);
898
+ const commandPath = resolveScript(hookDef.script);
899
+ if (!commandPath) {
900
+ errors.push(`${name}: script not found in user or system hooks dir`);
825
901
  continue;
826
902
  }
827
903
  const timeoutMs = (hookDef.timeout || 600) * 1000;
@@ -5,4 +5,4 @@
5
5
  * Each migration is guarded by an existence check so re-running is safe.
6
6
  */
7
7
  /** Run all idempotent migrations. Safe to call multiple times. */
8
- export declare function runMigration(): void;
8
+ export declare function runMigration(): Promise<void>;