@phnx-labs/agents-cli 1.20.59 → 1.20.61
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 +17 -1
- package/README.md +9 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +38 -1
- package/dist/commands/routines.js +2 -0
- package/dist/lib/agents.js +92 -4
- package/dist/lib/hosts/dispatch.d.ts +36 -0
- package/dist/lib/hosts/dispatch.js +40 -2
- package/dist/lib/permissions.d.ts +14 -5
- package/dist/lib/permissions.js +139 -28
- package/dist/lib/plugins.d.ts +8 -0
- package/dist/lib/plugins.js +108 -0
- package/dist/lib/resources/permissions.d.ts +1 -1
- package/dist/lib/resources/permissions.js +5 -1
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/routines.d.ts +23 -0
- package/dist/lib/routines.js +64 -1
- package/dist/lib/runner.d.ts +7 -0
- package/dist/lib/runner.js +34 -6
- package/dist/lib/shims.js +13 -3
- package/dist/lib/skills.js +14 -1
- package/dist/lib/staleness/detectors/permissions.js +28 -2
- package/dist/lib/staleness/detectors/subagents.js +20 -1
- package/dist/lib/staleness/detectors/workflows.js +62 -0
- package/dist/lib/staleness/writers/subagents.js +13 -5
- package/dist/lib/subagents.d.ts +10 -1
- package/dist/lib/subagents.js +102 -14
- package/dist/lib/tmux/session.d.ts +13 -7
- package/dist/lib/tmux/session.js +23 -8
- package/dist/lib/versions.js +84 -2
- package/dist/lib/workflows.d.ts +14 -3
- package/dist/lib/workflows.js +328 -9
- package/package.json +1 -1
package/dist/lib/permissions.js
CHANGED
|
@@ -306,14 +306,21 @@ export function buildPermissionsFromGroups(groupNames) {
|
|
|
306
306
|
// Matches lines like: - "Bash(git *)" or - "WebFetch(domain:example.com)"
|
|
307
307
|
// Handles nested quotes that break YAML parsers
|
|
308
308
|
const lines = content.split('\n');
|
|
309
|
+
let section = null;
|
|
309
310
|
for (const line of lines) {
|
|
311
|
+
const sectionMatch = line.match(/^\s*(allow|deny)\s*:\s*(?:#.*)?$/);
|
|
312
|
+
if (sectionMatch) {
|
|
313
|
+
section = sectionMatch[1];
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
310
316
|
// Match: optional whitespace, dash, whitespace, quote, content, quote
|
|
311
317
|
// Use greedy match to capture everything between first and last quote
|
|
312
318
|
const match = line.match(/^\s*-\s*"(.+)"$/);
|
|
313
319
|
if (match) {
|
|
314
320
|
const rule = match[1];
|
|
315
|
-
// 99-deny group rules go to deny, others
|
|
316
|
-
|
|
321
|
+
// 99-deny group rules go to deny, others follow their YAML section.
|
|
322
|
+
// Legacy group files used bare lists with no section; keep those as allow.
|
|
323
|
+
if (section === 'deny' || groupName === '99-deny' || groupName.includes('-deny')) {
|
|
317
324
|
allDeny.push(rule);
|
|
318
325
|
}
|
|
319
326
|
else {
|
|
@@ -486,32 +493,40 @@ function parseCanonicalPattern(permission) {
|
|
|
486
493
|
const BLANKET_BASH_FORMS = new Set(['Bash', 'Bash(*)', 'Bash(**)']);
|
|
487
494
|
/**
|
|
488
495
|
* Convert canonical permission set to Gemini format.
|
|
489
|
-
* Gemini reads tool allow
|
|
490
|
-
* Bash permissions map to
|
|
491
|
-
*
|
|
492
|
-
* Blanket Bash grants map to bare "run_shell_command" (no prefix filter).
|
|
496
|
+
* Gemini reads tool allow/deny lists from settings.json under
|
|
497
|
+
* `tools.core` / `tools.exclude`. Bash permissions map to ShellTool(pattern).
|
|
498
|
+
* Blanket Bash grants map to bare "ShellTool" (no command filter).
|
|
493
499
|
* Non-Bash tool patterns are skipped; Gemini uses different tool names.
|
|
494
500
|
*/
|
|
495
501
|
export function convertToGeminiFormat(set) {
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
502
|
+
const serialize = (permissions) => {
|
|
503
|
+
const tools = new Set();
|
|
504
|
+
for (const perm of permissions) {
|
|
505
|
+
if (BLANKET_BASH_FORMS.has(perm)) {
|
|
506
|
+
tools.add('ShellTool');
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const parsed = parseCanonicalPattern(perm);
|
|
510
|
+
if (!parsed || parsed.tool !== 'bash')
|
|
511
|
+
continue;
|
|
512
|
+
const command = normalizeBashPattern(parsed.pattern);
|
|
513
|
+
if (command === '*') {
|
|
514
|
+
tools.add('ShellTool');
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
tools.add(`ShellTool(${command})`);
|
|
518
|
+
}
|
|
512
519
|
}
|
|
513
|
-
|
|
514
|
-
|
|
520
|
+
return Array.from(tools);
|
|
521
|
+
};
|
|
522
|
+
const core = serialize(set.allow);
|
|
523
|
+
const exclude = serialize(set.deny ?? []);
|
|
524
|
+
return {
|
|
525
|
+
tools: {
|
|
526
|
+
core,
|
|
527
|
+
...(exclude.length ? { exclude } : {}),
|
|
528
|
+
},
|
|
529
|
+
};
|
|
515
530
|
}
|
|
516
531
|
/**
|
|
517
532
|
* Strip Claude's `:*` subcommand-wildcard suffix and return a space-glob form.
|
|
@@ -596,6 +611,49 @@ const ANTIGRAVITY_ACTION_BY_TOOL = {
|
|
|
596
611
|
write: 'write_file',
|
|
597
612
|
webfetch: 'read_url',
|
|
598
613
|
};
|
|
614
|
+
const GOOSE_TOOL_BY_CANONICAL = {
|
|
615
|
+
bash: 'developer__shell',
|
|
616
|
+
read: 'developer__text_editor',
|
|
617
|
+
write: 'developer__text_editor',
|
|
618
|
+
edit: 'developer__text_editor',
|
|
619
|
+
grep: 'developer__analyze',
|
|
620
|
+
glob: 'developer__analyze',
|
|
621
|
+
webfetch: 'developer__fetch',
|
|
622
|
+
mcp: undefined,
|
|
623
|
+
};
|
|
624
|
+
function canonicalToGooseTool(permission) {
|
|
625
|
+
if (BLANKET_BASH_FORMS.has(permission))
|
|
626
|
+
return 'developer__shell';
|
|
627
|
+
const parsed = parseCanonicalPattern(permission);
|
|
628
|
+
if (!parsed)
|
|
629
|
+
return null;
|
|
630
|
+
return GOOSE_TOOL_BY_CANONICAL[parsed.tool] ?? null;
|
|
631
|
+
}
|
|
632
|
+
/** Convert canonical permissions to Goose's per-tool permission.yaml shape. */
|
|
633
|
+
export function convertToGooseFormat(set) {
|
|
634
|
+
const alwaysAllow = new Set();
|
|
635
|
+
const neverAllow = new Set();
|
|
636
|
+
for (const permission of set.allow) {
|
|
637
|
+
const tool = canonicalToGooseTool(permission);
|
|
638
|
+
if (tool)
|
|
639
|
+
alwaysAllow.add(tool);
|
|
640
|
+
}
|
|
641
|
+
for (const permission of set.deny ?? []) {
|
|
642
|
+
const tool = canonicalToGooseTool(permission);
|
|
643
|
+
if (tool)
|
|
644
|
+
neverAllow.add(tool);
|
|
645
|
+
}
|
|
646
|
+
for (const tool of neverAllow) {
|
|
647
|
+
alwaysAllow.delete(tool);
|
|
648
|
+
}
|
|
649
|
+
return {
|
|
650
|
+
user: {
|
|
651
|
+
always_allow: Array.from(alwaysAllow).sort(),
|
|
652
|
+
ask_before: [],
|
|
653
|
+
never_allow: Array.from(neverAllow).sort(),
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
}
|
|
599
657
|
/**
|
|
600
658
|
* Convert canonical permission set to Grok format.
|
|
601
659
|
* Grok reads ~/.grok/config.toml with
|
|
@@ -1274,17 +1332,29 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
|
|
|
1274
1332
|
const geminiPerms = convertToGeminiFormat(set);
|
|
1275
1333
|
const settingsPath = path.join(versionHome, '.gemini', 'settings.json');
|
|
1276
1334
|
updateGeminiSettings(settingsPath, (settings) => {
|
|
1277
|
-
// Remove stale
|
|
1335
|
+
// Remove stale keys written by earlier serializer versions:
|
|
1336
|
+
// top-level `permissions`, and Gemini's pre-core/exclude `tools.allowed`.
|
|
1278
1337
|
delete settings.permissions;
|
|
1279
1338
|
const tools = (typeof settings.tools === 'object' && settings.tools !== null && !Array.isArray(settings.tools))
|
|
1280
1339
|
? settings.tools
|
|
1281
1340
|
: {};
|
|
1341
|
+
delete tools.allowed;
|
|
1282
1342
|
if (merge) {
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1343
|
+
const existingCore = Array.isArray(tools.core) ? tools.core : [];
|
|
1344
|
+
const existingExclude = Array.isArray(tools.exclude) ? tools.exclude : [];
|
|
1345
|
+
tools.core = Array.from(new Set([...existingCore, ...geminiPerms.tools.core]));
|
|
1346
|
+
const mergedExclude = Array.from(new Set([...existingExclude, ...(geminiPerms.tools.exclude ?? [])]));
|
|
1347
|
+
if (mergedExclude.length)
|
|
1348
|
+
tools.exclude = mergedExclude;
|
|
1349
|
+
else
|
|
1350
|
+
delete tools.exclude;
|
|
1285
1351
|
}
|
|
1286
1352
|
else {
|
|
1287
|
-
tools.
|
|
1353
|
+
tools.core = geminiPerms.tools.core;
|
|
1354
|
+
if (geminiPerms.tools.exclude?.length)
|
|
1355
|
+
tools.exclude = geminiPerms.tools.exclude;
|
|
1356
|
+
else
|
|
1357
|
+
delete tools.exclude;
|
|
1288
1358
|
}
|
|
1289
1359
|
settings.tools = tools;
|
|
1290
1360
|
});
|
|
@@ -1349,6 +1419,47 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
|
|
|
1349
1419
|
fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
|
|
1350
1420
|
return { success: true };
|
|
1351
1421
|
}
|
|
1422
|
+
if (agentId === 'goose') {
|
|
1423
|
+
const permissionsPath = path.join(versionHome, '.config', 'goose', 'permission.yaml');
|
|
1424
|
+
let config = {};
|
|
1425
|
+
if (fs.existsSync(permissionsPath)) {
|
|
1426
|
+
const parsed = yaml.parse(fs.readFileSync(permissionsPath, 'utf-8'));
|
|
1427
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1428
|
+
config = parsed;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const converted = convertToGooseFormat(set).user;
|
|
1432
|
+
const user = (typeof config.user === 'object' && config.user !== null && !Array.isArray(config.user))
|
|
1433
|
+
? config.user
|
|
1434
|
+
: {};
|
|
1435
|
+
const currentAlways = Array.isArray(user.always_allow) ? user.always_allow.filter((v) => typeof v === 'string') : [];
|
|
1436
|
+
const currentAsk = Array.isArray(user.ask_before) ? user.ask_before.filter((v) => typeof v === 'string') : [];
|
|
1437
|
+
const currentNever = Array.isArray(user.never_allow) ? user.never_allow.filter((v) => typeof v === 'string') : [];
|
|
1438
|
+
if (merge) {
|
|
1439
|
+
const incomingAlways = new Set(converted.always_allow);
|
|
1440
|
+
const incomingNever = new Set(converted.never_allow);
|
|
1441
|
+
const touched = new Set([...incomingAlways, ...incomingNever]);
|
|
1442
|
+
const always = new Set(currentAlways.filter(tool => !touched.has(tool)));
|
|
1443
|
+
const ask = new Set(currentAsk.filter(tool => !touched.has(tool)));
|
|
1444
|
+
const never = new Set(currentNever.filter(tool => !touched.has(tool)));
|
|
1445
|
+
for (const tool of incomingAlways)
|
|
1446
|
+
always.add(tool);
|
|
1447
|
+
for (const tool of incomingNever)
|
|
1448
|
+
never.add(tool);
|
|
1449
|
+
user.always_allow = Array.from(always).sort();
|
|
1450
|
+
user.ask_before = Array.from(ask).sort();
|
|
1451
|
+
user.never_allow = Array.from(never).sort();
|
|
1452
|
+
}
|
|
1453
|
+
else {
|
|
1454
|
+
user.always_allow = converted.always_allow;
|
|
1455
|
+
user.ask_before = converted.ask_before;
|
|
1456
|
+
user.never_allow = converted.never_allow;
|
|
1457
|
+
}
|
|
1458
|
+
config.user = user;
|
|
1459
|
+
fs.mkdirSync(path.dirname(permissionsPath), { recursive: true });
|
|
1460
|
+
fs.writeFileSync(permissionsPath, yaml.stringify(config), 'utf-8');
|
|
1461
|
+
return { success: true };
|
|
1462
|
+
}
|
|
1352
1463
|
if (agentId === 'kimi') {
|
|
1353
1464
|
const configPath = path.join(versionHome, '.kimi-code', 'config.toml');
|
|
1354
1465
|
let config = {};
|
package/dist/lib/plugins.d.ts
CHANGED
|
@@ -191,6 +191,14 @@ export declare function resolveOpenCodePluginSources(pluginRoot: string): string
|
|
|
191
191
|
export declare function installOpenCodePlugin(plugin: DiscoveredPlugin, versionHome: string): boolean;
|
|
192
192
|
export declare function isOpenCodePluginInstalled(pluginName: string, versionHome: string): boolean;
|
|
193
193
|
export declare function removeOpenCodePlugin(pluginName: string, versionHome: string): boolean;
|
|
194
|
+
/**
|
|
195
|
+
* Gemini CLI extensions live under `$HOME/.gemini/extensions/<name>/` and
|
|
196
|
+
* require a `gemini-extension.json` manifest at the extension root.
|
|
197
|
+
*/
|
|
198
|
+
export declare function geminiExtensionsDir(versionHome: string): string;
|
|
199
|
+
export declare function installGeminiPlugin(plugin: DiscoveredPlugin, versionHome: string): boolean;
|
|
200
|
+
export declare function isGeminiPluginInstalled(pluginName: string, versionHome: string): boolean;
|
|
201
|
+
export declare function removeGeminiPlugin(pluginName: string, versionHome: string): boolean;
|
|
194
202
|
/**
|
|
195
203
|
* Goose auto-discovers Open Plugins at `$HOME/.agents/plugins/<name>/`.
|
|
196
204
|
* Under agents-cli version isolation HOME is the version home, so we install to:
|
package/dist/lib/plugins.js
CHANGED
|
@@ -21,6 +21,7 @@ import { shouldInstallCommandAsSkill, installCommandSkillToVersion } from './com
|
|
|
21
21
|
import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, registerDroidInstalledPlugin, unregisterDroidInstalledPlugin, isDroidPluginInstalled, registerCopilotInstalledPlugin, unregisterCopilotInstalledPlugin, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
|
|
22
22
|
const PLUGIN_MANIFEST_DIR = '.claude-plugin';
|
|
23
23
|
const PLUGIN_MANIFEST_FILE = 'plugin.json';
|
|
24
|
+
const GEMINI_EXTENSION_MANIFEST_FILE = 'gemini-extension.json';
|
|
24
25
|
const USER_CONFIG_FILE = '.user-config.json';
|
|
25
26
|
const SOURCE_FILE = '.source';
|
|
26
27
|
export const PLUGIN_EXEC_SURFACE_LABELS = {
|
|
@@ -502,6 +503,27 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
|
|
|
502
503
|
result.skills.push(plugin.name);
|
|
503
504
|
return result;
|
|
504
505
|
}
|
|
506
|
+
// Gemini CLI loads extensions from $HOME/.gemini/extensions/<name>/.
|
|
507
|
+
// Copy the plugin bundle as an extension and synthesize gemini-extension.json.
|
|
508
|
+
if (agent === 'gemini') {
|
|
509
|
+
const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
|
|
510
|
+
if (!enablePlugin) {
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
const ok = installGeminiPlugin(plugin, versionHome);
|
|
514
|
+
result.success = ok;
|
|
515
|
+
if (ok) {
|
|
516
|
+
result.skills = plugin.skills.map(s => `${plugin.name}:${s}`);
|
|
517
|
+
result.commands = plugin.commands.map(c => `${plugin.name}:${c}`);
|
|
518
|
+
result.agentDefs = plugin.agentDefs.map(a => `${plugin.name}:${a}`);
|
|
519
|
+
result.bin = plugin.bin;
|
|
520
|
+
result.hooks = plugin.hooks;
|
|
521
|
+
result.mcp = plugin.hasMcp;
|
|
522
|
+
result.settings = plugin.hasSettings;
|
|
523
|
+
result.permissions = pluginHasPermissions(plugin);
|
|
524
|
+
}
|
|
525
|
+
return result;
|
|
526
|
+
}
|
|
505
527
|
// Goose loads Open Plugins from $HOME/.agents/plugins/<name>/ (same layout as
|
|
506
528
|
// agents-cli's source tree). Under the shim HOME is the version home.
|
|
507
529
|
if (agent === 'goose') {
|
|
@@ -901,6 +923,82 @@ export function removeOpenCodePlugin(pluginName, versionHome) {
|
|
|
901
923
|
}
|
|
902
924
|
return removed;
|
|
903
925
|
}
|
|
926
|
+
// ─── Gemini extensions ───────────────────────────────────────────────────────
|
|
927
|
+
/**
|
|
928
|
+
* Gemini CLI extensions live under `$HOME/.gemini/extensions/<name>/` and
|
|
929
|
+
* require a `gemini-extension.json` manifest at the extension root.
|
|
930
|
+
*/
|
|
931
|
+
export function geminiExtensionsDir(versionHome) {
|
|
932
|
+
return path.join(versionHome, '.gemini', 'extensions');
|
|
933
|
+
}
|
|
934
|
+
function readPluginMcpConfigForGemini(pluginRoot) {
|
|
935
|
+
const mcpPath = path.join(pluginRoot, '.mcp.json');
|
|
936
|
+
if (!fs.existsSync(mcpPath))
|
|
937
|
+
return undefined;
|
|
938
|
+
try {
|
|
939
|
+
const parsed = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
|
|
940
|
+
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object')
|
|
941
|
+
return undefined;
|
|
942
|
+
return rewriteGeminiExtensionVars(parsed.mcpServers);
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
return undefined;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function rewriteGeminiExtensionVars(value) {
|
|
949
|
+
if (typeof value === 'string') {
|
|
950
|
+
return value
|
|
951
|
+
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, '${extensionPath}')
|
|
952
|
+
.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, '${extensionPath}/.data');
|
|
953
|
+
}
|
|
954
|
+
if (Array.isArray(value))
|
|
955
|
+
return value.map(rewriteGeminiExtensionVars);
|
|
956
|
+
if (value && typeof value === 'object') {
|
|
957
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteGeminiExtensionVars(item)]));
|
|
958
|
+
}
|
|
959
|
+
return value;
|
|
960
|
+
}
|
|
961
|
+
function writeGeminiExtensionManifest(plugin, destRoot) {
|
|
962
|
+
const manifest = {
|
|
963
|
+
name: plugin.manifest.name,
|
|
964
|
+
version: plugin.manifest.version,
|
|
965
|
+
description: plugin.manifest.description,
|
|
966
|
+
};
|
|
967
|
+
const mcpServers = readPluginMcpConfigForGemini(destRoot);
|
|
968
|
+
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
|
969
|
+
manifest.mcpServers = mcpServers;
|
|
970
|
+
}
|
|
971
|
+
fs.writeFileSync(path.join(destRoot, GEMINI_EXTENSION_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
|
|
972
|
+
}
|
|
973
|
+
export function installGeminiPlugin(plugin, versionHome) {
|
|
974
|
+
const destRoot = path.join(geminiExtensionsDir(versionHome), plugin.name);
|
|
975
|
+
try {
|
|
976
|
+
if (fs.existsSync(destRoot)) {
|
|
977
|
+
fs.rmSync(destRoot, { recursive: true, force: true });
|
|
978
|
+
}
|
|
979
|
+
fs.cpSync(plugin.root, destRoot, { recursive: true });
|
|
980
|
+
const userConfig = loadUserConfig(plugin.name);
|
|
981
|
+
if (Object.keys(userConfig).length > 0) {
|
|
982
|
+
expandUserConfigInDir(destRoot, userConfig);
|
|
983
|
+
}
|
|
984
|
+
writeGeminiExtensionManifest(plugin, destRoot);
|
|
985
|
+
fs.writeFileSync(path.join(destRoot, '.agents-cli-managed'), `plugin=${plugin.name}\n`, 'utf-8');
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
return false;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
export function isGeminiPluginInstalled(pluginName, versionHome) {
|
|
993
|
+
return fs.existsSync(path.join(geminiExtensionsDir(versionHome), pluginName, GEMINI_EXTENSION_MANIFEST_FILE));
|
|
994
|
+
}
|
|
995
|
+
export function removeGeminiPlugin(pluginName, versionHome) {
|
|
996
|
+
const destRoot = path.join(geminiExtensionsDir(versionHome), pluginName);
|
|
997
|
+
if (!fs.existsSync(destRoot))
|
|
998
|
+
return false;
|
|
999
|
+
fs.rmSync(destRoot, { recursive: true, force: true });
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
904
1002
|
// ─── Goose plugins (Open Plugins under .agents/plugins/) ─────────────────────
|
|
905
1003
|
/**
|
|
906
1004
|
* Goose auto-discovers Open Plugins at `$HOME/.agents/plugins/<name>/`.
|
|
@@ -949,6 +1047,9 @@ export function isPluginSynced(plugin, agent, versionHome) {
|
|
|
949
1047
|
if (agent === 'opencode') {
|
|
950
1048
|
return isOpenCodePluginInstalled(plugin.name, versionHome);
|
|
951
1049
|
}
|
|
1050
|
+
if (agent === 'gemini') {
|
|
1051
|
+
return isGeminiPluginInstalled(plugin.name, versionHome);
|
|
1052
|
+
}
|
|
952
1053
|
if (agent === 'goose') {
|
|
953
1054
|
return isGoosePluginInstalled(plugin.name, versionHome);
|
|
954
1055
|
}
|
|
@@ -986,6 +1087,13 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
|
|
|
986
1087
|
}
|
|
987
1088
|
return result;
|
|
988
1089
|
}
|
|
1090
|
+
// Gemini: remove extension directory from ~/.gemini/extensions/.
|
|
1091
|
+
if (agent === 'gemini') {
|
|
1092
|
+
if (removeGeminiPlugin(pluginName, versionHome)) {
|
|
1093
|
+
result.skills.push(pluginName);
|
|
1094
|
+
}
|
|
1095
|
+
return result;
|
|
1096
|
+
}
|
|
989
1097
|
// Goose: remove Open Plugin directory from versionHome/.agents/plugins/.
|
|
990
1098
|
if (agent === 'goose') {
|
|
991
1099
|
if (removeGoosePlugin(pluginName, versionHome)) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Permissions are stored as YAML files in permissions/ directories at each layer.
|
|
5
5
|
* Resolution: project > user > system (higher layer wins on name conflict).
|
|
6
6
|
* Unlike other resources, permissions merge into agent-specific config files
|
|
7
|
-
* (Claude: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
|
|
7
|
+
* (Claude/Gemini: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
|
|
8
8
|
*/
|
|
9
9
|
import type { ResourceHandler } from './types.js';
|
|
10
10
|
import type { PermissionSet } from '../types.js';
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Permissions are stored as YAML files in permissions/ directories at each layer.
|
|
5
5
|
* Resolution: project > user > system (higher layer wins on name conflict).
|
|
6
6
|
* Unlike other resources, permissions merge into agent-specific config files
|
|
7
|
-
* (Claude: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
|
|
7
|
+
* (Claude/Gemini: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
|
|
8
8
|
*/
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
@@ -65,6 +65,8 @@ function getAgentConfigPath(agent, versionHome) {
|
|
|
65
65
|
return path.join(versionHome, '.claude', 'settings.json');
|
|
66
66
|
case 'codex':
|
|
67
67
|
return path.join(versionHome, '.codex', 'config.toml');
|
|
68
|
+
case 'gemini':
|
|
69
|
+
return path.join(versionHome, '.gemini', 'settings.json');
|
|
68
70
|
case 'opencode':
|
|
69
71
|
return path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
|
|
70
72
|
case 'kimi':
|
|
@@ -73,6 +75,8 @@ function getAgentConfigPath(agent, versionHome) {
|
|
|
73
75
|
return path.join(versionHome, '.factory', 'settings.json');
|
|
74
76
|
case 'kiro':
|
|
75
77
|
return path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
|
|
78
|
+
case 'goose':
|
|
79
|
+
return path.join(versionHome, '.config', 'goose', 'permission.yaml');
|
|
76
80
|
default:
|
|
77
81
|
return null;
|
|
78
82
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - Union: All resources from all layers are combined
|
|
6
6
|
* - Override on name conflict: Higher layer wins (project > user > system)
|
|
7
7
|
*/
|
|
8
|
-
export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'kiro' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'forge';
|
|
8
|
+
export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'forge';
|
|
9
9
|
export type Layer = 'system' | 'user' | 'project';
|
|
10
10
|
export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow' | 'memory';
|
|
11
11
|
/** A resolved resource with its origin layer. */
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -138,6 +145,22 @@ export declare function readJob(name: string, cwd?: string): JobConfig | null;
|
|
|
138
145
|
* name, the write fails explicitly so we never choose or drop a sibling.
|
|
139
146
|
*/
|
|
140
147
|
export declare function writeJob(config: JobConfig): void;
|
|
148
|
+
/**
|
|
149
|
+
* Serialize a job config, preserving the on-disk formatting of an existing file.
|
|
150
|
+
*
|
|
151
|
+
* A full `yaml.stringify(config)` re-emits the whole document — restyling every
|
|
152
|
+
* scalar (unquoting `schedule`, re-wrapping the folded `prompt` block, reordering
|
|
153
|
+
* keys). When a routine is only being toggled (pause/resume) or re-pinned
|
|
154
|
+
* (`devices --set`), that rewrites the entire file, leaving the git-backed
|
|
155
|
+
* `~/.agents` tree perpetually dirty so `agents repo pull` refuses to sync across
|
|
156
|
+
* the fleet. To keep the diff to the field that actually changed, we edit the
|
|
157
|
+
* existing document in place and only re-render touched nodes; untouched nodes
|
|
158
|
+
* (notably the large `prompt` block) keep their byte-for-byte formatting.
|
|
159
|
+
*
|
|
160
|
+
* `existingText` is the current file contents, or null for a new file. New,
|
|
161
|
+
* unparseable, and non-mapping documents fall back to canonical `yaml.stringify`.
|
|
162
|
+
*/
|
|
163
|
+
export declare function serializeJob(output: Record<string, unknown>, existingText: string | null): string;
|
|
141
164
|
/** Delete a job config file by name. Returns true if the file existed. */
|
|
142
165
|
export declare function deleteJob(name: string): boolean;
|
|
143
166
|
/** Enable or disable a job by name. */
|
package/dist/lib/routines.js
CHANGED
|
@@ -187,7 +187,52 @@ export function writeJob(config) {
|
|
|
187
187
|
const devArr = output.devices;
|
|
188
188
|
if (!devArr || devArr.length === 0)
|
|
189
189
|
delete output.devices;
|
|
190
|
-
|
|
190
|
+
let existingText = null;
|
|
191
|
+
if (ymlExists || yamlExists) {
|
|
192
|
+
try {
|
|
193
|
+
existingText = fs.readFileSync(filePath, 'utf-8');
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
existingText = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
atomicWriteFileSync(filePath, serializeJob(output, existingText));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Serialize a job config, preserving the on-disk formatting of an existing file.
|
|
203
|
+
*
|
|
204
|
+
* A full `yaml.stringify(config)` re-emits the whole document — restyling every
|
|
205
|
+
* scalar (unquoting `schedule`, re-wrapping the folded `prompt` block, reordering
|
|
206
|
+
* keys). When a routine is only being toggled (pause/resume) or re-pinned
|
|
207
|
+
* (`devices --set`), that rewrites the entire file, leaving the git-backed
|
|
208
|
+
* `~/.agents` tree perpetually dirty so `agents repo pull` refuses to sync across
|
|
209
|
+
* the fleet. To keep the diff to the field that actually changed, we edit the
|
|
210
|
+
* existing document in place and only re-render touched nodes; untouched nodes
|
|
211
|
+
* (notably the large `prompt` block) keep their byte-for-byte formatting.
|
|
212
|
+
*
|
|
213
|
+
* `existingText` is the current file contents, or null for a new file. New,
|
|
214
|
+
* unparseable, and non-mapping documents fall back to canonical `yaml.stringify`.
|
|
215
|
+
*/
|
|
216
|
+
export function serializeJob(output, existingText) {
|
|
217
|
+
if (existingText == null)
|
|
218
|
+
return yaml.stringify(output);
|
|
219
|
+
const doc = yaml.parseDocument(existingText);
|
|
220
|
+
if (doc.errors.length > 0 || !yaml.isMap(doc.contents))
|
|
221
|
+
return yaml.stringify(output);
|
|
222
|
+
const existing = (doc.toJS() ?? {});
|
|
223
|
+
// Update or add only the keys that actually changed; leave the rest untouched
|
|
224
|
+
// so their original formatting is preserved.
|
|
225
|
+
for (const [key, value] of Object.entries(output)) {
|
|
226
|
+
if (JSON.stringify(existing[key]) !== JSON.stringify(value))
|
|
227
|
+
doc.set(key, value);
|
|
228
|
+
}
|
|
229
|
+
// Drop keys that no longer belong (e.g. an omitted default, or `devices`
|
|
230
|
+
// cleared back to fleet-wide).
|
|
231
|
+
for (const key of Object.keys(existing)) {
|
|
232
|
+
if (!(key in output))
|
|
233
|
+
doc.delete(key);
|
|
234
|
+
}
|
|
235
|
+
return doc.toString();
|
|
191
236
|
}
|
|
192
237
|
/** Delete a job config file by name. Returns true if the file existed. */
|
|
193
238
|
export function deleteJob(name) {
|
|
@@ -253,6 +298,24 @@ export function validateJob(config) {
|
|
|
253
298
|
errors.push('workflow must be a lowercase alphanumeric name (hyphens and underscores allowed, e.g. autodev)');
|
|
254
299
|
}
|
|
255
300
|
}
|
|
301
|
+
if (config.resume !== undefined) {
|
|
302
|
+
// Only claude/codex support native `--resume`; other agents would emit a resume
|
|
303
|
+
// flag they don't understand. Resume reopens an agent session, so it is
|
|
304
|
+
// incompatible with workflow and loop jobs (which have no single session to reopen).
|
|
305
|
+
const RESUMABLE_AGENTS = ['claude', 'codex'];
|
|
306
|
+
if (typeof config.resume !== 'string' || config.resume.trim() === '') {
|
|
307
|
+
errors.push('resume must be a non-empty session id string');
|
|
308
|
+
}
|
|
309
|
+
if (hasWorkflow) {
|
|
310
|
+
errors.push('resume cannot be combined with workflow (resume reopens an existing agent session)');
|
|
311
|
+
}
|
|
312
|
+
if (config.loop) {
|
|
313
|
+
errors.push('resume cannot be combined with loop');
|
|
314
|
+
}
|
|
315
|
+
if (hasAgent && config.agent && !RESUMABLE_AGENTS.includes(config.agent)) {
|
|
316
|
+
errors.push(`resume is only supported for agents with native --resume (${RESUMABLE_AGENTS.join(', ')}); got '${config.agent}'`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
256
319
|
if (config.mode && !['plan', 'edit', 'auto', 'skip', 'full'].includes(config.mode)) {
|
|
257
320
|
errors.push("mode must be plan, edit, auto, or skip ('full' accepted as alias for skip)");
|
|
258
321
|
}
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -50,6 +50,13 @@ export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string): P
|
|
|
50
50
|
* surface as "agents: no version of X configured".
|
|
51
51
|
*/
|
|
52
52
|
export declare function pinJobBinary(cmd: string[], agent: AgentId, version: string | undefined): string[];
|
|
53
|
+
/**
|
|
54
|
+
* Whether a job's command is dispatched through `agents run` (so `cmd[0] === 'agents'`)
|
|
55
|
+
* rather than the agent binary directly. True for workflow jobs and for resume jobs.
|
|
56
|
+
* Such commands must NOT be binary-pinned (pinning rewrites cmd[0] to the agent binary,
|
|
57
|
+
* producing a broken `<binary> run …`) and must not receive a version-pinned spawn env.
|
|
58
|
+
*/
|
|
59
|
+
export declare function dispatchesViaAgentsRun(config: Pick<JobConfig, 'workflow' | 'resume'>): boolean;
|
|
53
60
|
/**
|
|
54
61
|
* Merge sandbox/base env with the canonical per-version exec env
|
|
55
62
|
* (CLAUDE_CONFIG_DIR / CODEX_HOME / …) so routines share account isolation
|
package/dist/lib/runner.js
CHANGED
|
@@ -44,6 +44,14 @@ export function buildJobCommand(config, resolvedPrompt) {
|
|
|
44
44
|
const cmd = ['agents', 'run', config.workflow, resolvedPrompt, '--mode', config.mode];
|
|
45
45
|
return cmd;
|
|
46
46
|
}
|
|
47
|
+
// Resume branch: reopen an EXISTING session via `agents run <agent> --resume <id>`
|
|
48
|
+
// instead of starting fresh. The real session resumes with its full prior context
|
|
49
|
+
// (index-based lookup, cwd-independent) and `resolvedPrompt` becomes its next turn —
|
|
50
|
+
// so a self-scheduled wake (e.g. /hibernate) is handled by the session that scheduled
|
|
51
|
+
// it, not a fresh, context-less agent that would refuse an "opaque" instruction.
|
|
52
|
+
if (config.resume) {
|
|
53
|
+
return ['agents', 'run', config.agent, '--resume', config.resume, resolvedPrompt, '--mode', config.mode];
|
|
54
|
+
}
|
|
47
55
|
const template = AGENT_COMMANDS[config.agent];
|
|
48
56
|
if (!template) {
|
|
49
57
|
throw new Error(`Unsupported agent for daemon jobs: ${config.agent}`);
|
|
@@ -263,6 +271,15 @@ export function pinJobBinary(cmd, agent, version) {
|
|
|
263
271
|
next[0] = binary;
|
|
264
272
|
return next;
|
|
265
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Whether a job's command is dispatched through `agents run` (so `cmd[0] === 'agents'`)
|
|
276
|
+
* rather than the agent binary directly. True for workflow jobs and for resume jobs.
|
|
277
|
+
* Such commands must NOT be binary-pinned (pinning rewrites cmd[0] to the agent binary,
|
|
278
|
+
* producing a broken `<binary> run …`) and must not receive a version-pinned spawn env.
|
|
279
|
+
*/
|
|
280
|
+
export function dispatchesViaAgentsRun(config) {
|
|
281
|
+
return Boolean(config.workflow || config.resume);
|
|
282
|
+
}
|
|
266
283
|
/**
|
|
267
284
|
* Merge sandbox/base env with the canonical per-version exec env
|
|
268
285
|
* (CLAUDE_CONFIG_DIR / CODEX_HOME / …) so routines share account isolation
|
|
@@ -394,7 +411,11 @@ export async function executeJob(config, deps) {
|
|
|
394
411
|
schedule: config.schedule,
|
|
395
412
|
});
|
|
396
413
|
const resolvedPrompt = resolveJobPrompt(config);
|
|
397
|
-
|
|
414
|
+
// Resume must run against the REAL home: `--resume <id>` resolves the session from
|
|
415
|
+
// the agent's config dir, and the sandbox overlay home has only a freshly-generated
|
|
416
|
+
// config with no session store. So a resume job is never sandboxed, regardless of
|
|
417
|
+
// `config.sandbox` (see the resume branch in buildJobCommand).
|
|
418
|
+
const useSandbox = config.sandbox !== false && !config.resume;
|
|
398
419
|
const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
|
|
399
420
|
const runId = generateRunId();
|
|
400
421
|
const runDir = getRunDir(config.name, runId);
|
|
@@ -470,10 +491,11 @@ export async function executeJob(config, deps) {
|
|
|
470
491
|
if (i === 0) {
|
|
471
492
|
process.stderr.write(`[agents] routine ${config.name}: running ${label}\n`);
|
|
472
493
|
}
|
|
473
|
-
const
|
|
494
|
+
const viaAgentsRun = dispatchesViaAgentsRun(config);
|
|
495
|
+
const cmd = viaAgentsRun
|
|
474
496
|
? baseCmd
|
|
475
497
|
: pinJobBinary(baseCmd, attemptAgent, attemptVersion);
|
|
476
|
-
const spawnEnv =
|
|
498
|
+
const spawnEnv = viaAgentsRun
|
|
477
499
|
? (() => {
|
|
478
500
|
const e = { ...baseEnv };
|
|
479
501
|
if (config.timezone)
|
|
@@ -553,10 +575,16 @@ export async function executeJobDetached(config) {
|
|
|
553
575
|
const version = launch.chain[0]?.version ?? config.version;
|
|
554
576
|
const resolvedPrompt = resolveJobPrompt(config);
|
|
555
577
|
let cmd = buildJobCommand(config, resolvedPrompt);
|
|
556
|
-
|
|
578
|
+
// workflow AND resume dispatch through `agents run` — never binary-pin them (pinning
|
|
579
|
+
// rewrites cmd[0] to the agent binary → broken `<binary> run …`).
|
|
580
|
+
if (!dispatchesViaAgentsRun(config) && version) {
|
|
557
581
|
cmd = pinJobBinary(cmd, config.agent, version);
|
|
558
582
|
}
|
|
559
|
-
|
|
583
|
+
// Resume must run against the REAL home: `--resume <id>` resolves the session from
|
|
584
|
+
// the agent's config dir, and the sandbox overlay home has only a freshly-generated
|
|
585
|
+
// config with no session store. So a resume job is never sandboxed, regardless of
|
|
586
|
+
// `config.sandbox` (see the resume branch in buildJobCommand).
|
|
587
|
+
const useSandbox = config.sandbox !== false && !config.resume;
|
|
560
588
|
const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
|
|
561
589
|
const runId = generateRunId();
|
|
562
590
|
const runDir = getRunDir(config.name, runId);
|
|
@@ -566,7 +594,7 @@ export async function executeJobDetached(config) {
|
|
|
566
594
|
const baseEnv = useSandbox
|
|
567
595
|
? buildSpawnEnv(overlayHome)
|
|
568
596
|
: { ...process.env };
|
|
569
|
-
const spawnEnv = config
|
|
597
|
+
const spawnEnv = dispatchesViaAgentsRun(config)
|
|
570
598
|
? (() => {
|
|
571
599
|
const e = { ...baseEnv };
|
|
572
600
|
if (config.timezone)
|