@phnx-labs/agents-cli 1.20.61 → 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.
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:
@@ -1,6 +1,6 @@
1
1
  import type { CloudTaskStatus } from '../cloud/types.js';
2
2
  import { type PidSessionEntry } from './pid-registry.js';
3
- import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
3
+ import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
4
4
  import type { SessionAttachment } from './types.js';
5
5
  import { type SessionProvenance } from './provenance.js';
6
6
  /**
@@ -46,6 +46,13 @@ export interface ActiveSession {
46
46
  * says whether it is still pending.
47
47
  */
48
48
  plan?: string;
49
+ /**
50
+ * Live plan progress from the most recent `TodoWrite` (RUSH-1380): the checklist
51
+ * items + a done/total tally + the current step. The Factory Floor renders this
52
+ * as an N/M pill + checklist for every session — including remote and
53
+ * device-dispatched agents that have no local tool-call stream to parse.
54
+ */
55
+ todos?: TodoProgress;
49
56
  /** Last few assistant turns (most-recent last), for at-a-glance context in the UI. */
50
57
  tail?: string[];
51
58
  /** PR opened during the session. */
@@ -267,6 +267,7 @@ function applyState(base, state, fallbackFile) {
267
267
  awaitingReason: state.awaitingReason,
268
268
  question: state.question,
269
269
  plan: state.plan,
270
+ todos: state.todos,
270
271
  tail: state.tail,
271
272
  // Prefer the live preview (latest turn); keep the first-prompt topic as a fallback.
272
273
  preview: state.preview ?? base.preview,
@@ -225,6 +225,18 @@ export function summarizeToolUse(tool, args) {
225
225
  const steps = Array.isArray(args.plan) ? args.plan.length : 0;
226
226
  return `Plan: ${steps} step${steps === 1 ? '' : 's'}`;
227
227
  }
228
+ // Claude's live checklist: show progress + the current step, not a bare "TodoWrite".
229
+ case 'TodoWrite': {
230
+ const todos = Array.isArray(args.todos) ? args.todos : [];
231
+ if (todos.length === 0)
232
+ return 'Plan: 0 steps';
233
+ const done = todos.filter((t) => t?.status === 'completed').length;
234
+ const active = todos.find((t) => t?.status === 'in_progress');
235
+ const step = active?.activeForm || active?.content;
236
+ return step
237
+ ? `Plan ${done}/${todos.length}: ${truncate(String(step), 80)}`
238
+ : `Plan: ${done}/${todos.length} done`;
239
+ }
228
240
  // Codex tools
229
241
  case 'exec_command':
230
242
  return `Bash: ${truncate(String(args.command || args.cmd || '').replace(/\n/g, ' ').trim(), 120)}`;
@@ -41,6 +41,28 @@ export interface StructuredQuestion {
41
41
  reason: AwaitingReason;
42
42
  options?: QuestionOption[];
43
43
  }
44
+ /** One `TodoWrite` checklist item, as Claude's plan tool emits it. */
45
+ export interface TodoItem {
46
+ content: string;
47
+ status: 'pending' | 'in_progress' | 'completed';
48
+ /** Present-continuous label shown while this item is the active step. */
49
+ activeForm?: string;
50
+ }
51
+ /**
52
+ * Live plan progress derived from the most recent `TodoWrite` in the transcript
53
+ * (RUSH-1380). Lets the Factory Floor show "N/M done" + the current step for any
54
+ * session — including remote / device-dispatched agents that carry no local
55
+ * tool-call stream — instead of only a coarse working/idle verb.
56
+ */
57
+ export interface TodoProgress {
58
+ items: TodoItem[];
59
+ /** Count of completed items. */
60
+ done: number;
61
+ /** Total items. */
62
+ total: number;
63
+ /** The in-progress item's activeForm (falls back to its content). The live step. */
64
+ activeForm?: string;
65
+ }
44
66
  export interface DetectedPr {
45
67
  url: string;
46
68
  number?: number;
@@ -88,6 +110,12 @@ export interface SessionState {
88
110
  * render the actual plan without re-parsing the transcript.
89
111
  */
90
112
  plan?: string;
113
+ /**
114
+ * Live plan progress from the most recent `TodoWrite` (RUSH-1380). Present when
115
+ * the session has written a todo list; drives the Factory Floor N/M pill +
116
+ * checklist, notably for remote/device-dispatched agents with no local stream.
117
+ */
118
+ todos?: TodoProgress;
91
119
  /** Last few assistant turns (most-recent last), one line each — panel context. */
92
120
  tail?: string[];
93
121
  lastActivityMs?: number;
@@ -111,6 +139,11 @@ export interface StateContext {
111
139
  /** Override the running window (defaults to 2 min, matching active.ts). */
112
140
  activeWindowMs?: number;
113
141
  }
142
+ /**
143
+ * Derive live plan progress from a `TodoWrite` tool call's args. Returns undefined
144
+ * when there is no usable list, so a session with no plan carries no `todos` field.
145
+ */
146
+ export declare function extractTodoProgress(args?: Record<string, any>): TodoProgress | undefined;
114
147
  /** Detect a worktree from the session cwd, per the `.agents/worktrees/<slug>/` convention. */
115
148
  export declare function detectWorktree(cwd?: string, branch?: string): DetectedWorktree | undefined;
116
149
  /** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
@@ -47,6 +47,40 @@ const PROSE_QUESTION_FRESH_MS = 30 * 60_000;
47
47
  /** Claude tool names that structurally mean "the agent handed control back to you". */
48
48
  const PLAN_TOOL = 'ExitPlanMode';
49
49
  const ASK_TOOL = 'AskUserQuestion';
50
+ /** Claude's live plan/checklist tool. */
51
+ const TODO_TOOL = 'TodoWrite';
52
+ /**
53
+ * Derive live plan progress from a `TodoWrite` tool call's args. Returns undefined
54
+ * when there is no usable list, so a session with no plan carries no `todos` field.
55
+ */
56
+ export function extractTodoProgress(args) {
57
+ const raw = args?.todos;
58
+ if (!Array.isArray(raw) || raw.length === 0)
59
+ return undefined;
60
+ const items = [];
61
+ for (const t of raw) {
62
+ const activeForm = typeof t?.activeForm === 'string' && t.activeForm ? t.activeForm : undefined;
63
+ const content = typeof t?.content === 'string' && t.content
64
+ ? t.content
65
+ : typeof t?.text === 'string' && t.text
66
+ ? t.text
67
+ : activeForm ?? '';
68
+ if (!content)
69
+ continue;
70
+ const status = t?.status === 'completed' || t?.status === 'in_progress' ? t.status : 'pending';
71
+ items.push(activeForm ? { content, status, activeForm } : { content, status });
72
+ }
73
+ if (items.length === 0)
74
+ return undefined;
75
+ const done = items.filter(i => i.status === 'completed').length;
76
+ const inProgress = items.find(i => i.status === 'in_progress');
77
+ return {
78
+ items,
79
+ done,
80
+ total: items.length,
81
+ activeForm: inProgress ? inProgress.activeForm ?? inProgress.content : undefined,
82
+ };
83
+ }
50
84
  /** Trailing '?' or a leading interrogative — a question aimed at the user. */
51
85
  const QUESTION_TRAILING = /\?["'”)\]]?\s*$/;
52
86
  const QUESTION_PHRASE = /\b(shall i|should i|do you want|would you like|which (?:one|option|approach|of)|can you (?:confirm|clarify)|please (?:confirm|clarify|advise)|let me know|are you (?:ok|okay|sure)|proceed\?)\b/i;
@@ -281,12 +315,18 @@ export function inferActivity(events, ctx = {}) {
281
315
  .slice(-3)
282
316
  .map(e => oneLine(e.content ?? ''))
283
317
  .filter(Boolean);
318
+ // Live plan progress: the most recent TodoWrite's checklist (RUSH-1380). Attached
319
+ // to `base` so every return path below carries it — a working, waiting, or idle
320
+ // session all keep showing how far the plan got.
321
+ const lastTodo = lastOf(meaningful, e => e.type === 'tool_use' && e.tool === TODO_TOOL);
322
+ const todos = lastTodo ? extractTodoProgress(lastTodo.args) : undefined;
284
323
  const base = {
285
324
  activity: 'idle',
286
325
  lastRole: lastMsg?.role,
287
326
  lastEventKind: last?.type,
288
327
  lastActivityMs: ctx.mtimeMs,
289
328
  preview: previewSource ? describeEvent(previewSource) : undefined,
329
+ todos,
290
330
  tail: tail.length ? tail : undefined,
291
331
  };
292
332
  if (!last)