@phnx-labs/agents-cli 1.20.60 → 1.20.62

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 (41) hide show
  1. package/CHANGELOG.md +18 -2
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +10 -1
  5. package/dist/commands/routines.js +2 -0
  6. package/dist/lib/agents.js +129 -11
  7. package/dist/lib/commands.js +29 -0
  8. package/dist/lib/convert.d.ts +11 -0
  9. package/dist/lib/convert.js +22 -0
  10. package/dist/lib/doctor-diff.js +17 -1
  11. package/dist/lib/goose-commands.d.ts +41 -0
  12. package/dist/lib/goose-commands.js +176 -0
  13. package/dist/lib/hooks.js +134 -0
  14. package/dist/lib/permissions.d.ts +15 -0
  15. package/dist/lib/permissions.js +99 -0
  16. package/dist/lib/plugins.d.ts +20 -0
  17. package/dist/lib/plugins.js +118 -0
  18. package/dist/lib/resources/permissions.js +2 -0
  19. package/dist/lib/routines.d.ts +7 -0
  20. package/dist/lib/routines.js +18 -0
  21. package/dist/lib/runner.d.ts +7 -0
  22. package/dist/lib/runner.js +34 -6
  23. package/dist/lib/session/active.d.ts +8 -1
  24. package/dist/lib/session/active.js +1 -0
  25. package/dist/lib/session/parse.js +12 -0
  26. package/dist/lib/session/state.d.ts +33 -0
  27. package/dist/lib/session/state.js +40 -0
  28. package/dist/lib/staleness/detectors/permissions.js +23 -0
  29. package/dist/lib/staleness/detectors/subagents.js +36 -0
  30. package/dist/lib/staleness/detectors/workflows.js +29 -0
  31. package/dist/lib/staleness/writers/commands.js +7 -0
  32. package/dist/lib/staleness/writers/hooks.js +1 -1
  33. package/dist/lib/staleness/writers/subagents.js +22 -3
  34. package/dist/lib/subagents.d.ts +32 -0
  35. package/dist/lib/subagents.js +238 -0
  36. package/dist/lib/tmux/session.d.ts +13 -7
  37. package/dist/lib/tmux/session.js +23 -8
  38. package/dist/lib/versions.js +72 -0
  39. package/dist/lib/workflows.d.ts +9 -0
  40. package/dist/lib/workflows.js +84 -2
  41. package/package.json +1 -1
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Goose slash-command install/remove/list/compare.
3
+ *
4
+ * Goose has no native slash-command file format — a slash command is a recipe
5
+ * YAML file registered in `~/.config/goose/config.yaml` under a `slash_commands`
6
+ * array: `[{ command: "<name>", recipe_path: "<abs path to recipe.yaml>" }]`.
7
+ * (See goose-docs.ai context-engineering/slash-commands.)
8
+ *
9
+ * agents-cli writes each command's recipe to `<versionHome>/.config/goose/commands/
10
+ * <name>.yaml` — a dir distinct from the workflow recipes dir
11
+ * (`.config/goose/recipes/`) so the workflow detector never treats a command
12
+ * recipe as a workflow — and registers/unregisters the `slash_commands` entry in
13
+ * `config.yaml` via a read-modify-write that preserves every other key
14
+ * (`mcp_servers`, `extensions`, …). Under agents-cli version isolation HOME is the
15
+ * version home, so both files live under it and the absolute `recipe_path`
16
+ * resolves correctly at goose runtime.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import * as yaml from 'yaml';
21
+ import { safeJoin } from './paths.js';
22
+ import { markdownToGooseRecipe } from './convert.js';
23
+ /** Directory holding Goose slash-command recipe YAML files in a version home. */
24
+ export function gooseCommandsDir(versionHome) {
25
+ return path.join(versionHome, '.config', 'goose', 'commands');
26
+ }
27
+ /** Path to the Goose config.yaml (holds the `slash_commands` registry) in a version home. */
28
+ export function gooseCommandConfigPath(versionHome) {
29
+ return path.join(versionHome, '.config', 'goose', 'config.yaml');
30
+ }
31
+ /**
32
+ * Read the goose config.yaml as a mutable object. Throws — rather than returning
33
+ * `{}` — when a NON-EMPTY file fails to parse or isn't a mapping, so the caller
34
+ * (which rewrites the whole file) never silently clobbers a real user config
35
+ * (`mcp_servers`, `GOOSE_MODEL`, `extensions`, …). A missing or genuinely empty
36
+ * file returns `{}`.
37
+ */
38
+ function readGooseConfig(configPath) {
39
+ if (!fs.existsSync(configPath))
40
+ return {};
41
+ const raw = fs.readFileSync(configPath, 'utf-8');
42
+ if (raw.trim() === '')
43
+ return {};
44
+ let parsed;
45
+ try {
46
+ parsed = yaml.parse(raw);
47
+ }
48
+ catch (err) {
49
+ throw new Error(`Refusing to rewrite goose config at ${configPath}: existing file is not valid YAML ` +
50
+ `(${err.message}). Fix or remove it, then re-sync.`);
51
+ }
52
+ if (parsed === null || parsed === undefined)
53
+ return {}; // comments/whitespace only
54
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
55
+ throw new Error(`Refusing to rewrite goose config at ${configPath}: expected a YAML mapping but found ` +
56
+ `${Array.isArray(parsed) ? 'a list' : typeof parsed}.`);
57
+ }
58
+ return parsed;
59
+ }
60
+ function readSlashCommands(config) {
61
+ const raw = config.slash_commands;
62
+ if (!Array.isArray(raw))
63
+ return [];
64
+ return raw.filter((e) => !!e && typeof e === 'object' && typeof e.command === 'string');
65
+ }
66
+ /**
67
+ * Register (or update) a `slash_commands` entry for `commandName` pointing at
68
+ * `recipePath`, preserving every other config key and other entries. Idempotent.
69
+ */
70
+ function registerSlashCommand(configPath, commandName, recipePath) {
71
+ const config = readGooseConfig(configPath);
72
+ const entries = readSlashCommands(config);
73
+ const existing = entries.find((e) => e.command === commandName);
74
+ if (existing && existing.recipe_path === recipePath)
75
+ return; // already current — no rewrite
76
+ const next = entries.filter((e) => e.command !== commandName);
77
+ next.push({ command: commandName, recipe_path: recipePath });
78
+ next.sort((a, b) => a.command.localeCompare(b.command));
79
+ config.slash_commands = next;
80
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
81
+ fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
82
+ }
83
+ /** Remove the `slash_commands` entry for `commandName`, preserving all other config. */
84
+ function unregisterSlashCommand(configPath, commandName) {
85
+ if (!fs.existsSync(configPath))
86
+ return;
87
+ const config = readGooseConfig(configPath);
88
+ const entries = readSlashCommands(config);
89
+ if (!entries.some((e) => e.command === commandName))
90
+ return; // nothing to do
91
+ const next = entries.filter((e) => e.command !== commandName);
92
+ if (next.length > 0) {
93
+ config.slash_commands = next;
94
+ }
95
+ else {
96
+ delete config.slash_commands;
97
+ }
98
+ fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
99
+ }
100
+ /** Serialize a Goose command recipe from a central Markdown command file. */
101
+ function buildGooseCommandRecipe(commandName, sourcePath) {
102
+ const markdown = fs.readFileSync(sourcePath, 'utf-8');
103
+ return yaml.stringify(markdownToGooseRecipe(commandName, markdown));
104
+ }
105
+ /**
106
+ * Install a command into a Goose version home: write its recipe YAML and register
107
+ * the `slash_commands` entry in config.yaml.
108
+ */
109
+ export function installGooseCommandToVersion(versionHome, commandName, sourcePath) {
110
+ try {
111
+ const dir = gooseCommandsDir(versionHome);
112
+ fs.mkdirSync(dir, { recursive: true });
113
+ const recipePath = safeJoin(dir, `${commandName}.yaml`);
114
+ fs.writeFileSync(recipePath, buildGooseCommandRecipe(commandName, sourcePath), 'utf-8');
115
+ registerSlashCommand(gooseCommandConfigPath(versionHome), commandName, recipePath);
116
+ return { success: true };
117
+ }
118
+ catch (err) {
119
+ return { success: false, error: err.message };
120
+ }
121
+ }
122
+ /** List Goose command names (recipe files) installed in a version home. */
123
+ export function listGooseCommandsInVersion(versionHome) {
124
+ const dir = gooseCommandsDir(versionHome);
125
+ if (!fs.existsSync(dir))
126
+ return [];
127
+ return fs.readdirSync(dir)
128
+ .filter((f) => f.endsWith('.yaml'))
129
+ .map((f) => f.slice(0, -'.yaml'.length))
130
+ .sort();
131
+ }
132
+ /** Whether an installed Goose command recipe matches the central Markdown source. */
133
+ export function gooseCommandMatches(versionHome, commandName, sourcePath) {
134
+ const recipePath = safeJoin(gooseCommandsDir(versionHome), `${commandName}.yaml`);
135
+ if (!fs.existsSync(recipePath) || !fs.existsSync(sourcePath))
136
+ return false;
137
+ try {
138
+ // The slash_commands entry must also be registered for the command to be live.
139
+ // (An unparseable config.yaml surfaces here as "not a match" → a re-sync, which
140
+ // fails loudly rather than clobbering, instead of a crash during a read-only diff.)
141
+ const registered = readSlashCommands(readGooseConfig(gooseCommandConfigPath(versionHome)))
142
+ .some((e) => e.command === commandName);
143
+ if (!registered)
144
+ return false;
145
+ const installed = fs.readFileSync(recipePath, 'utf-8').trim();
146
+ const expected = buildGooseCommandRecipe(commandName, sourcePath).trim();
147
+ return installed === expected;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * Remove a Goose command from a version home: soft-delete the recipe to `trashDir`
155
+ * (when provided) and unregister its `slash_commands` entry.
156
+ */
157
+ export function removeGooseCommandFromVersion(versionHome, commandName, trashDir) {
158
+ try {
159
+ const recipePath = safeJoin(gooseCommandsDir(versionHome), `${commandName}.yaml`);
160
+ if (fs.existsSync(recipePath)) {
161
+ if (trashDir) {
162
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
163
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
164
+ fs.renameSync(recipePath, path.join(trashDir, `${commandName}.yaml.${stamp}`));
165
+ }
166
+ else {
167
+ fs.unlinkSync(recipePath);
168
+ }
169
+ }
170
+ unregisterSlashCommand(gooseCommandConfigPath(versionHome), commandName);
171
+ return { success: true };
172
+ }
173
+ catch (err) {
174
+ return { success: false, error: err.message };
175
+ }
176
+ }
package/dist/lib/hooks.js CHANGED
@@ -1006,6 +1006,9 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1006
1006
  if (agentId === 'cursor') {
1007
1007
  return registerHooksForCursor(versionHome, manifest, resolveScript, managedPrefixes);
1008
1008
  }
1009
+ if (agentId === 'hermes') {
1010
+ return registerHooksForHermes(versionHome, manifest, resolveScript, managedPrefixes);
1011
+ }
1009
1012
  return { registered: [], errors: [] };
1010
1013
  }
1011
1014
  /**
@@ -2186,3 +2189,134 @@ function registerHooksForCursor(versionHome, manifest, resolveScript, managedPre
2186
2189
  }
2187
2190
  return { registered, errors };
2188
2191
  }
2192
+ /**
2193
+ * Canonical → Hermes (Nous Research) snake_case lifecycle events.
2194
+ *
2195
+ * Hermes ≥ 0.11.0 runs configurable hooks declared under `hooks:` in
2196
+ * ~/.hermes/config.yaml. Only events with a documented Hermes equivalent are
2197
+ * mapped; unmapped canonical events (the manifest may declare events for other
2198
+ * agents) are skipped silently. UserPromptSubmit maps to `pre_llm_call` (the
2199
+ * closest pre-turn phase) and Stop to `on_session_finalize`.
2200
+ */
2201
+ const HERMES_EVENT_MAP = {
2202
+ SessionStart: 'on_session_start',
2203
+ SessionEnd: 'on_session_end',
2204
+ PreToolUse: 'pre_tool_call',
2205
+ PostToolUse: 'post_tool_call',
2206
+ SubagentStop: 'subagent_stop',
2207
+ UserPromptSubmit: 'pre_llm_call',
2208
+ Stop: 'on_session_finalize',
2209
+ };
2210
+ /** Hermes caps hook timeouts at 300s (default 60s). */
2211
+ const HERMES_TIMEOUT_CAP = 300;
2212
+ const HERMES_TIMEOUT_DEFAULT = 60;
2213
+ /**
2214
+ * Register hooks for Hermes Agent (Nous Research ≥ 0.11.0).
2215
+ *
2216
+ * Read-modify-writes the shared `~/.hermes/config.yaml` (under the version
2217
+ * home): it merges a `hooks:` block of the form
2218
+ * hooks: { <event>: [ { command, timeout, matcher? } ] }
2219
+ * into the YAML doc WITHOUT touching sibling keys (`mcp_servers` in
2220
+ * particular — a naive overwrite would wipe the user's MCP servers). No
2221
+ * `version` wrapper: Hermes' config is a flat YAML map.
2222
+ *
2223
+ * GC: rewrite managed entries by command path under managedPrefixes; preserve
2224
+ * user-authored entries whose command is outside managed roots. Keyed by
2225
+ * event|command|matcher so a matcher or event change drops the stale entry.
2226
+ */
2227
+ function registerHooksForHermes(versionHome, manifest, resolveScript, managedPrefixes) {
2228
+ const registered = [];
2229
+ const errors = [];
2230
+ const configDir = path.join(versionHome, '.hermes');
2231
+ const configPath = path.join(configDir, 'config.yaml');
2232
+ // Read-modify-write: preserve the full existing YAML doc (mcp_servers, etc.).
2233
+ let config = {};
2234
+ if (fs.existsSync(configPath)) {
2235
+ try {
2236
+ const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
2237
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
2238
+ config = parsed;
2239
+ }
2240
+ }
2241
+ catch {
2242
+ errors.push('Failed to parse existing config.yaml');
2243
+ return { registered, errors };
2244
+ }
2245
+ }
2246
+ const existingHooks = config.hooks && typeof config.hooks === 'object' && !Array.isArray(config.hooks)
2247
+ ? config.hooks
2248
+ : {};
2249
+ // Desired managed entries keyed by event|command|matcher so a matcher or
2250
+ // event change drops the stale entry instead of retaining it by command alone.
2251
+ const desiredManaged = new Set();
2252
+ for (const [hookName, hookDef] of Object.entries(manifest)) {
2253
+ if (!hookDef.events || hookDef.events.length === 0)
2254
+ continue;
2255
+ const resolved = resolveHookCommand(hookName, hookDef, resolveScript);
2256
+ if (!resolved)
2257
+ continue;
2258
+ for (const event of hookDef.events) {
2259
+ const hermesEvent = HERMES_EVENT_MAP[event];
2260
+ if (!hermesEvent)
2261
+ continue;
2262
+ desiredManaged.add(`${hermesEvent}|${resolved}|${hookDef.matcher ?? ''}`);
2263
+ }
2264
+ }
2265
+ // GC managed entries that are no longer in the manifest; preserve user entries.
2266
+ const hooks = {};
2267
+ for (const [event, entries] of Object.entries(existingHooks)) {
2268
+ if (!Array.isArray(entries))
2269
+ continue;
2270
+ hooks[event] = entries.filter((e) => {
2271
+ if (typeof e?.command !== 'string')
2272
+ return true;
2273
+ if (!isManagedHookCommand(e.command, managedPrefixes))
2274
+ return true;
2275
+ return desiredManaged.has(`${event}|${e.command}|${e.matcher ?? ''}`);
2276
+ });
2277
+ if (hooks[event].length === 0)
2278
+ delete hooks[event];
2279
+ }
2280
+ for (const [name, hookDef] of Object.entries(manifest)) {
2281
+ if (!hookDef.events || hookDef.events.length === 0)
2282
+ continue;
2283
+ const commandPath = resolveHookCommand(name, hookDef, resolveScript);
2284
+ if (!commandPath) {
2285
+ errors.push(`${name}: script not found in user or system hooks dir`);
2286
+ continue;
2287
+ }
2288
+ const timeout = Math.min(HERMES_TIMEOUT_CAP, hookDef.timeout ?? HERMES_TIMEOUT_DEFAULT);
2289
+ for (const event of hookDef.events) {
2290
+ const hermesEvent = HERMES_EVENT_MAP[event];
2291
+ if (!hermesEvent)
2292
+ continue;
2293
+ if (!hooks[hermesEvent])
2294
+ hooks[hermesEvent] = [];
2295
+ const entry = { command: commandPath, timeout };
2296
+ if (hookDef.matcher)
2297
+ entry.matcher = hookDef.matcher;
2298
+ const existingIdx = hooks[hermesEvent].findIndex((h) => h.command === entry.command && (h.matcher ?? '') === (entry.matcher ?? ''));
2299
+ if (existingIdx >= 0) {
2300
+ hooks[hermesEvent][existingIdx] = entry;
2301
+ }
2302
+ else {
2303
+ hooks[hermesEvent].push(entry);
2304
+ }
2305
+ registered.push(`${name} -> ${hermesEvent}`);
2306
+ }
2307
+ }
2308
+ if (Object.keys(hooks).length > 0) {
2309
+ config.hooks = hooks;
2310
+ }
2311
+ else {
2312
+ delete config.hooks;
2313
+ }
2314
+ try {
2315
+ fs.mkdirSync(configDir, { recursive: true });
2316
+ fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
2317
+ }
2318
+ catch (err) {
2319
+ errors.push(`Failed to write config.yaml: ${err.message}`);
2320
+ }
2321
+ return { registered, errors };
2322
+ }
@@ -122,6 +122,21 @@ export declare function convertToDroidFormat(set: PermissionSet): {
122
122
  commandAllowlist: string[];
123
123
  commandDenylist: string[];
124
124
  };
125
+ /**
126
+ * Convert canonical permission set to OpenClaw's `tools.alsoAllow`/`tools.deny`.
127
+ *
128
+ * OpenClaw's allowlist is tool-level only, so ONLY blanket (whole-tool) rules
129
+ * map — a rule is blanket iff it's a bare tool with no parens (`Bash`), it's in
130
+ * BLANKET_BASH_FORMS, or its pattern is `*`/`**` (`Read(**)`, `Write(*)`).
131
+ * Sub-command/path/domain rules (`Bash(git:*)`, `Write(secrets/**)`,
132
+ * `WebFetch(domain:x)`) are SKIPPED — coarse-mapping a specific deny to a whole
133
+ * tool would wrongly gate every use of that tool. Output arrays are deduped and
134
+ * sorted for deterministic writes/tests.
135
+ */
136
+ export declare function convertToOpenClawFormat(set: PermissionSet): {
137
+ alsoAllow: string[];
138
+ deny: string[];
139
+ };
125
140
  /**
126
141
  * Convert canonical permission set to Antigravity format.
127
142
  * Antigravity reads ~/.gemini/antigravity-cli/settings.json with
@@ -560,6 +560,60 @@ export function convertToDroidFormat(set) {
560
560
  commandDenylist: commands(set.deny ?? []),
561
561
  };
562
562
  }
563
+ /**
564
+ * Canonical tool -> OpenClaw tool id. OpenClaw gates at TOOL granularity only
565
+ * (no sub-command/path/domain patterns), so only these whole-tool ids exist.
566
+ * Any canonical tool not in this map is unsupported and skipped.
567
+ */
568
+ const CANONICAL_TO_OPENCLAW_TOOL = {
569
+ bash: 'exec',
570
+ read: 'read',
571
+ write: 'write',
572
+ edit: 'write',
573
+ webfetch: 'web_fetch',
574
+ websearch: 'web_search',
575
+ };
576
+ /**
577
+ * Convert canonical permission set to OpenClaw's `tools.alsoAllow`/`tools.deny`.
578
+ *
579
+ * OpenClaw's allowlist is tool-level only, so ONLY blanket (whole-tool) rules
580
+ * map — a rule is blanket iff it's a bare tool with no parens (`Bash`), it's in
581
+ * BLANKET_BASH_FORMS, or its pattern is `*`/`**` (`Read(**)`, `Write(*)`).
582
+ * Sub-command/path/domain rules (`Bash(git:*)`, `Write(secrets/**)`,
583
+ * `WebFetch(domain:x)`) are SKIPPED — coarse-mapping a specific deny to a whole
584
+ * tool would wrongly gate every use of that tool. Output arrays are deduped and
585
+ * sorted for deterministic writes/tests.
586
+ */
587
+ export function convertToOpenClawFormat(set) {
588
+ const map = (permissions) => {
589
+ const tools = new Set();
590
+ for (const perm of permissions) {
591
+ // Bare tool name with no parens (e.g. "Bash", "Read") is a blanket grant;
592
+ // parseCanonicalPattern requires parens, so handle it first.
593
+ const bare = perm.match(/^(\w+)$/);
594
+ if (bare) {
595
+ const id = CANONICAL_TO_OPENCLAW_TOOL[bare[1].toLowerCase()];
596
+ if (id)
597
+ tools.add(id);
598
+ continue;
599
+ }
600
+ const parsed = parseCanonicalPattern(perm);
601
+ if (!parsed)
602
+ continue;
603
+ const isBlanket = BLANKET_BASH_FORMS.has(perm) || parsed.pattern === '*' || parsed.pattern === '**';
604
+ if (!isBlanket)
605
+ continue;
606
+ const id = CANONICAL_TO_OPENCLAW_TOOL[parsed.tool];
607
+ if (id)
608
+ tools.add(id);
609
+ }
610
+ return Array.from(tools).sort();
611
+ };
612
+ return {
613
+ alsoAllow: map(set.allow),
614
+ deny: map(set.deny ?? []),
615
+ };
616
+ }
563
617
  /**
564
618
  * Convert canonical permission set to Antigravity format.
565
619
  * Antigravity reads ~/.gemini/antigravity-cli/settings.json with
@@ -1570,6 +1624,51 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1570
1624
  fs.writeFileSync(permissionsPath, yaml.stringify(config), 'utf-8');
1571
1625
  return { success: true };
1572
1626
  }
1627
+ if (agentId === 'openclaw') {
1628
+ // OpenClaw's allowlist lives in ~/.openclaw/openclaw.json under `tools`.
1629
+ // Only blanket tool-level rules map (see convertToOpenClawFormat). We
1630
+ // read-modify-write to preserve all other keys (mcp, exec, agents, …) and
1631
+ // never touch `tools.allow` (the absolute allowlist that replaces defaults).
1632
+ const configPath = path.join(versionHome, '.openclaw', 'openclaw.json');
1633
+ let config = {};
1634
+ if (fs.existsSync(configPath)) {
1635
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1636
+ }
1637
+ const converted = convertToOpenClawFormat(set);
1638
+ const existingTools = (typeof config.tools === 'object' && config.tools !== null && !Array.isArray(config.tools))
1639
+ ? config.tools
1640
+ : {};
1641
+ let alsoAllow;
1642
+ let deny;
1643
+ if (merge) {
1644
+ const existingAllow = Array.isArray(existingTools.alsoAllow) ? existingTools.alsoAllow : [];
1645
+ const existingDeny = Array.isArray(existingTools.deny) ? existingTools.deny : [];
1646
+ alsoAllow = Array.from(new Set([...existingAllow, ...converted.alsoAllow]));
1647
+ deny = Array.from(new Set([...existingDeny, ...converted.deny]));
1648
+ }
1649
+ else {
1650
+ alsoAllow = converted.alsoAllow;
1651
+ deny = converted.deny;
1652
+ }
1653
+ // Set or delete each key: avoid writing empty arrays (churn). On a
1654
+ // non-merge replace with nothing to write, delete the stale key.
1655
+ const tools = { ...existingTools };
1656
+ if (alsoAllow.length > 0)
1657
+ tools.alsoAllow = alsoAllow;
1658
+ else
1659
+ delete tools.alsoAllow;
1660
+ if (deny.length > 0)
1661
+ tools.deny = deny;
1662
+ else
1663
+ delete tools.deny;
1664
+ if (Object.keys(tools).length > 0)
1665
+ config.tools = tools;
1666
+ else
1667
+ delete config.tools;
1668
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1669
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1670
+ return { success: true };
1671
+ }
1573
1672
  return { success: false, error: `Agent '${agentId}' does not support permissions` };
1574
1673
  }
1575
1674
  catch (err) {
@@ -211,6 +211,26 @@ export declare function goosePluginsDir(versionHome: string): string;
211
211
  export declare function installGoosePlugin(plugin: DiscoveredPlugin, versionHome: string): boolean;
212
212
  export declare function isGoosePluginInstalled(pluginName: string, versionHome: string): boolean;
213
213
  export declare function removeGoosePlugin(pluginName: string, versionHome: string): boolean;
214
+ /**
215
+ * Hermes (Nous Research) loads plugins from a flat `$HOME/.hermes/plugins/<name>/`
216
+ * directory holding a `plugin.yaml` manifest — NOT the Claude marketplace layout.
217
+ * Under agents-cli version isolation HOME is the version home, so we install to:
218
+ * {versionHome}/.hermes/plugins/<name>/
219
+ * A plugin does not load until its name is added to `plugins.enabled` (a YAML
220
+ * array) in `{versionHome}/.hermes/config.yaml`; a deny-list `plugins.disabled`
221
+ * wins on conflict, so agents-cli only manages the `enabled` allowlist and never
222
+ * touches `disabled` (user-owned).
223
+ */
224
+ export declare function hermesPluginsDir(versionHome: string): string;
225
+ /**
226
+ * Add or remove a plugin name in `plugins.enabled` within ~/.hermes/config.yaml,
227
+ * preserving every other key (read → mutate → write). Never touches
228
+ * `plugins.disabled`. No-op (no rewrite) when the desired state already holds.
229
+ */
230
+ export declare function setHermesPluginEnabled(pluginName: string, versionHome: string, enabled: boolean): void;
231
+ export declare function installHermesPlugin(plugin: DiscoveredPlugin, versionHome: string, enable: boolean): boolean;
232
+ export declare function isHermesPluginInstalled(pluginName: string, versionHome: string): boolean;
233
+ export declare function removeHermesPlugin(pluginName: string, versionHome: string): boolean;
214
234
  /**
215
235
  * Check if a plugin is synced to a version. True when the plugin lives at the
216
236
  * native marketplace install path. Legacy dual-dash entries are not counted —
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
+ import * as yaml from 'yaml';
13
14
  import { execFileSync } from 'child_process';
14
15
  import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir, getSystemPluginsDir } from './state.js';
15
16
  import { IS_WINDOWS, isWindowsAbsolutePath, homeDir } from './platform/index.js';
@@ -22,6 +23,7 @@ import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace,
22
23
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
23
24
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
24
25
  const GEMINI_EXTENSION_MANIFEST_FILE = 'gemini-extension.json';
26
+ const HERMES_PLUGIN_MANIFEST_FILE = 'plugin.yaml';
25
27
  const USER_CONFIG_FILE = '.user-config.json';
26
28
  const SOURCE_FILE = '.source';
27
29
  export const PLUGIN_EXEC_SURFACE_LABELS = {
@@ -533,6 +535,16 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
533
535
  result.skills.push(plugin.name);
534
536
  return result;
535
537
  }
538
+ // Hermes loads plugins from a flat $HOME/.hermes/plugins/<name>/ dir with a
539
+ // plugin.yaml manifest, gated by a plugins.enabled allowlist in config.yaml.
540
+ if (agent === 'hermes') {
541
+ const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
542
+ const ok = installHermesPlugin(plugin, versionHome, enablePlugin);
543
+ result.success = ok;
544
+ if (ok)
545
+ result.skills.push(plugin.name);
546
+ return result;
547
+ }
536
548
  const userConfig = loadUserConfig(plugin.name);
537
549
  // Route every marketplace op through the plugin's own marketplace, so a plugin
538
550
  // discovered in an extra/project repo installs under its own
@@ -1035,6 +1047,102 @@ export function removeGoosePlugin(pluginName, versionHome) {
1035
1047
  fs.rmSync(destRoot, { recursive: true, force: true });
1036
1048
  return true;
1037
1049
  }
1050
+ // ─── Hermes plugins (flat ~/.hermes/plugins/ + config.yaml enable toggle) ─────
1051
+ /**
1052
+ * Hermes (Nous Research) loads plugins from a flat `$HOME/.hermes/plugins/<name>/`
1053
+ * directory holding a `plugin.yaml` manifest — NOT the Claude marketplace layout.
1054
+ * Under agents-cli version isolation HOME is the version home, so we install to:
1055
+ * {versionHome}/.hermes/plugins/<name>/
1056
+ * A plugin does not load until its name is added to `plugins.enabled` (a YAML
1057
+ * array) in `{versionHome}/.hermes/config.yaml`; a deny-list `plugins.disabled`
1058
+ * wins on conflict, so agents-cli only manages the `enabled` allowlist and never
1059
+ * touches `disabled` (user-owned).
1060
+ */
1061
+ export function hermesPluginsDir(versionHome) {
1062
+ return path.join(versionHome, '.hermes', 'plugins');
1063
+ }
1064
+ function hermesConfigPath(versionHome) {
1065
+ return path.join(versionHome, '.hermes', 'config.yaml');
1066
+ }
1067
+ function writeHermesPluginManifest(plugin, destRoot) {
1068
+ const manifest = {
1069
+ name: plugin.manifest.name,
1070
+ version: plugin.manifest.version,
1071
+ description: plugin.manifest.description,
1072
+ };
1073
+ fs.writeFileSync(path.join(destRoot, HERMES_PLUGIN_MANIFEST_FILE), yaml.stringify(manifest), 'utf-8');
1074
+ }
1075
+ /**
1076
+ * Add or remove a plugin name in `plugins.enabled` within ~/.hermes/config.yaml,
1077
+ * preserving every other key (read → mutate → write). Never touches
1078
+ * `plugins.disabled`. No-op (no rewrite) when the desired state already holds.
1079
+ */
1080
+ export function setHermesPluginEnabled(pluginName, versionHome, enabled) {
1081
+ const configPath = hermesConfigPath(versionHome);
1082
+ let config = {};
1083
+ if (fs.existsSync(configPath)) {
1084
+ const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
1085
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1086
+ config = parsed;
1087
+ }
1088
+ }
1089
+ if (!config.plugins || typeof config.plugins !== 'object' || Array.isArray(config.plugins)) {
1090
+ config.plugins = {};
1091
+ }
1092
+ const plugins = config.plugins;
1093
+ const current = Array.isArray(plugins.enabled) ? plugins.enabled.filter((n) => typeof n === 'string') : [];
1094
+ const has = current.includes(pluginName);
1095
+ if (enabled && !has) {
1096
+ plugins.enabled = [...current, pluginName];
1097
+ }
1098
+ else if (!enabled && has) {
1099
+ plugins.enabled = current.filter((n) => n !== pluginName);
1100
+ }
1101
+ else {
1102
+ return; // desired state already holds — no rewrite
1103
+ }
1104
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1105
+ fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
1106
+ }
1107
+ export function installHermesPlugin(plugin, versionHome, enable) {
1108
+ const destRoot = path.join(hermesPluginsDir(versionHome), plugin.name);
1109
+ try {
1110
+ if (fs.existsSync(destRoot)) {
1111
+ fs.rmSync(destRoot, { recursive: true, force: true });
1112
+ }
1113
+ fs.cpSync(plugin.root, destRoot, { recursive: true });
1114
+ const userConfig = loadUserConfig(plugin.name);
1115
+ if (Object.keys(userConfig).length > 0) {
1116
+ expandUserConfigInDir(destRoot, userConfig);
1117
+ }
1118
+ writeHermesPluginManifest(plugin, destRoot);
1119
+ fs.writeFileSync(path.join(destRoot, '.agents-cli-managed'), `plugin=${plugin.name}\n`, 'utf-8');
1120
+ // Enable only when trusted — never DOWN-toggle here. An un-flagged background
1121
+ // re-sync of an exec-surface plugin passes enable=false; forcing the allowlist
1122
+ // to false then would clobber a plugin the user deliberately enabled with
1123
+ // --allow-exec-surfaces. Mirror addPluginToSettings: add-if-trusted, else leave
1124
+ // the existing enabled state untouched. (Removal still unregisters explicitly.)
1125
+ if (enable) {
1126
+ setHermesPluginEnabled(plugin.name, versionHome, true);
1127
+ }
1128
+ return true;
1129
+ }
1130
+ catch {
1131
+ return false;
1132
+ }
1133
+ }
1134
+ export function isHermesPluginInstalled(pluginName, versionHome) {
1135
+ return fs.existsSync(path.join(hermesPluginsDir(versionHome), pluginName, HERMES_PLUGIN_MANIFEST_FILE));
1136
+ }
1137
+ export function removeHermesPlugin(pluginName, versionHome) {
1138
+ const destRoot = path.join(hermesPluginsDir(versionHome), pluginName);
1139
+ const existed = fs.existsSync(destRoot);
1140
+ if (existed)
1141
+ fs.rmSync(destRoot, { recursive: true, force: true });
1142
+ // Always drop it from the enabled allowlist, even if the dir was already gone.
1143
+ setHermesPluginEnabled(pluginName, versionHome, false);
1144
+ return existed;
1145
+ }
1038
1146
  // ─── Sync status ──────────────────────────────────────────────────────────────
1039
1147
  /**
1040
1148
  * Check if a plugin is synced to a version. True when the plugin lives at the
@@ -1053,6 +1161,9 @@ export function isPluginSynced(plugin, agent, versionHome) {
1053
1161
  if (agent === 'goose') {
1054
1162
  return isGoosePluginInstalled(plugin.name, versionHome);
1055
1163
  }
1164
+ if (agent === 'hermes') {
1165
+ return isHermesPluginInstalled(plugin.name, versionHome);
1166
+ }
1056
1167
  const spec = marketplaceSpecForName(plugin.marketplace);
1057
1168
  if (!isInstalledInMarketplace(plugin.name, spec, agent, versionHome))
1058
1169
  return false;
@@ -1101,6 +1212,13 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
1101
1212
  }
1102
1213
  return result;
1103
1214
  }
1215
+ // Hermes: remove the flat plugin dir and drop it from config.yaml plugins.enabled.
1216
+ if (agent === 'hermes') {
1217
+ if (removeHermesPlugin(pluginName, versionHome)) {
1218
+ result.skills.push(pluginName);
1219
+ }
1220
+ return result;
1221
+ }
1104
1222
  // 1. Remove the plugin from every marketplace it's installed under. A name can
1105
1223
  // appear in more than one (collision across repos), so we sweep them all.
1106
1224
  let removedAny = false;
@@ -75,6 +75,8 @@ function getAgentConfigPath(agent, versionHome) {
75
75
  return path.join(versionHome, '.factory', 'settings.json');
76
76
  case 'kiro':
77
77
  return path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
78
+ case 'openclaw':
79
+ return path.join(versionHome, '.openclaw', 'openclaw.json');
78
80
  case 'goose':
79
81
  return path.join(versionHome, '.config', 'goose', 'permission.yaml');
80
82
  default:
@@ -75,6 +75,13 @@ export interface JobConfig {
75
75
  version?: string;
76
76
  runOnce?: boolean;
77
77
  endAt?: string;
78
+ /**
79
+ * When set, the job resumes this existing agent session id at fire time
80
+ * (`agents run <agent> --resume <id>`) instead of starting a fresh conversation,
81
+ * so the actual session reopens with full context and `prompt` becomes its next
82
+ * turn. Powers self-scheduled wake-ups (e.g. /hibernate). claude/codex only.
83
+ */
84
+ resume?: string;
78
85
  /** When set, executeJob runs this job through the loop driver instead of once. */
79
86
  loop?: LoopConfig;
80
87
  }