@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/CHANGELOG.md +12 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +10 -1
- package/dist/lib/agents.js +42 -10
- package/dist/lib/commands.js +29 -0
- package/dist/lib/convert.d.ts +11 -0
- package/dist/lib/convert.js +22 -0
- package/dist/lib/doctor-diff.js +17 -1
- package/dist/lib/goose-commands.d.ts +41 -0
- package/dist/lib/goose-commands.js +176 -0
- package/dist/lib/hooks.js +134 -0
- package/dist/lib/permissions.d.ts +15 -0
- package/dist/lib/permissions.js +99 -0
- package/dist/lib/plugins.d.ts +20 -0
- package/dist/lib/plugins.js +118 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/session/active.d.ts +8 -1
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +12 -0
- package/dist/lib/session/state.d.ts +33 -0
- package/dist/lib/session/state.js +40 -0
- package/dist/lib/staleness/detectors/permissions.js +23 -0
- package/dist/lib/staleness/detectors/subagents.js +36 -0
- package/dist/lib/staleness/writers/commands.js +7 -0
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/staleness/writers/subagents.js +22 -3
- package/dist/lib/subagents.d.ts +32 -0
- package/dist/lib/subagents.js +238 -0
- package/package.json +1 -1
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
|
package/dist/lib/permissions.js
CHANGED
|
@@ -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) {
|
package/dist/lib/plugins.d.ts
CHANGED
|
@@ -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 —
|
package/dist/lib/plugins.js
CHANGED
|
@@ -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)
|