@phnx-labs/agents-cli 1.20.60 → 1.20.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -2
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +10 -1
- package/dist/commands/routines.js +2 -0
- package/dist/lib/agents.js +129 -11
- 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/routines.d.ts +7 -0
- package/dist/lib/routines.js +18 -0
- package/dist/lib/runner.d.ts +7 -0
- package/dist/lib/runner.js +34 -6
- 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/detectors/workflows.js +29 -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/dist/lib/tmux/session.d.ts +13 -7
- package/dist/lib/tmux/session.js +23 -8
- package/dist/lib/versions.js +72 -0
- package/dist/lib/workflows.d.ts +9 -0
- package/dist/lib/workflows.js +84 -2
- package/package.json +1 -1
package/dist/lib/subagents.js
CHANGED
|
@@ -274,6 +274,27 @@ export function transformSubagentForDroid(subagentDir) {
|
|
|
274
274
|
* See GitHub docs for custom agents.
|
|
275
275
|
*/
|
|
276
276
|
export const transformSubagentForCopilot = transformSubagentForDroid;
|
|
277
|
+
/**
|
|
278
|
+
* Transform a subagent into a Cursor CLI custom subagent `.md` file.
|
|
279
|
+
*
|
|
280
|
+
* Cursor loads subagents from `.cursor/agents/*.md` (project) or
|
|
281
|
+
* `~/.cursor/agents/*.md` (user) — Markdown with YAML frontmatter
|
|
282
|
+
* (name, description, model; also readonly/is_background, which our
|
|
283
|
+
* frontmatter schema doesn't carry). Cursor has no `color` field, so this is
|
|
284
|
+
* an alias of transformSubagentForDroid, same as Copilot.
|
|
285
|
+
* See https://cursor.com/docs/subagents.
|
|
286
|
+
*/
|
|
287
|
+
export const transformSubagentForCursor = transformSubagentForDroid;
|
|
288
|
+
/**
|
|
289
|
+
* Transform a subagent into a ForgeCode custom subagent `.md` file.
|
|
290
|
+
*
|
|
291
|
+
* ForgeCode loads named agents from `.forge/agents/*.md` (project) or
|
|
292
|
+
* `~/.forge/agents/*.md` (user) — Markdown with YAML frontmatter (id, title,
|
|
293
|
+
* description, tools, model, temperature) + a system-prompt body. ForgeCode has
|
|
294
|
+
* no `color` field, so this is an alias of transformSubagentForDroid, same as
|
|
295
|
+
* Copilot/Cursor. See https://forgecode.dev/docs/agent-definition-guide/.
|
|
296
|
+
*/
|
|
297
|
+
export const transformSubagentForForge = transformSubagentForDroid;
|
|
277
298
|
/**
|
|
278
299
|
* Transform a subagent into Antigravity's custom-agent markdown shape.
|
|
279
300
|
*
|
|
@@ -493,6 +514,45 @@ export function transformSubagentForKiro(subagentDir) {
|
|
|
493
514
|
}
|
|
494
515
|
return JSON.stringify(config, null, 2);
|
|
495
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* Transform a subagent into a Goose recipe YAML file.
|
|
519
|
+
*
|
|
520
|
+
* Goose has no dedicated subagent file format — a named subagent IS a recipe.
|
|
521
|
+
* Goose resolves named agents from `~/.config/goose/agents/<name>.yaml` (global)
|
|
522
|
+
* and delegates to them by name in autonomous mode. The recipe schema mirrors the
|
|
523
|
+
* one agents-cli already emits for Goose workflow recipes (`writeGooseSubrecipe`):
|
|
524
|
+
* `version`, `title`, `description`, `instructions`, `prompt`, plus an optional
|
|
525
|
+
* `settings.goose_model`. See goose-docs.ai context-engineering/subagents.
|
|
526
|
+
*/
|
|
527
|
+
export function transformSubagentForGoose(subagentDir) {
|
|
528
|
+
const agentMd = path.join(subagentDir, 'AGENT.md');
|
|
529
|
+
const frontmatter = parseSubagentFrontmatter(agentMd);
|
|
530
|
+
const body = getSubagentBody(agentMd);
|
|
531
|
+
if (!frontmatter) {
|
|
532
|
+
throw new Error(`Invalid AGENT.md in ${subagentDir}`);
|
|
533
|
+
}
|
|
534
|
+
const files = fs.readdirSync(subagentDir)
|
|
535
|
+
.filter(f => f.endsWith('.md') && f !== 'AGENT.md')
|
|
536
|
+
.sort();
|
|
537
|
+
let prompt = body || frontmatter.description || frontmatter.name;
|
|
538
|
+
for (const file of files) {
|
|
539
|
+
const content = fs.readFileSync(path.join(subagentDir, file), 'utf-8').trim();
|
|
540
|
+
const sectionName = file.replace('.md', '');
|
|
541
|
+
const title = sectionName.charAt(0).toUpperCase() + sectionName.slice(1).toLowerCase();
|
|
542
|
+
prompt += `\n\n## ${title}\n\n${content}`;
|
|
543
|
+
}
|
|
544
|
+
const recipe = {
|
|
545
|
+
version: '1.0.0',
|
|
546
|
+
title: frontmatter.name || path.basename(subagentDir),
|
|
547
|
+
description: frontmatter.description || frontmatter.name || path.basename(subagentDir),
|
|
548
|
+
instructions: prompt,
|
|
549
|
+
prompt,
|
|
550
|
+
};
|
|
551
|
+
if (frontmatter.model) {
|
|
552
|
+
recipe.settings = { goose_model: frontmatter.model };
|
|
553
|
+
}
|
|
554
|
+
return yaml.stringify(recipe);
|
|
555
|
+
}
|
|
496
556
|
/**
|
|
497
557
|
* Sync a subagent to an OpenClaw workspace
|
|
498
558
|
* Copies full directory, renames AGENT.md to AGENTS.md
|
|
@@ -609,6 +669,51 @@ export function installSubagentToAgent(subagentDir, subagentName, agent, agentHo
|
|
|
609
669
|
return { success: false, error: String(err) };
|
|
610
670
|
}
|
|
611
671
|
}
|
|
672
|
+
else if (agent === 'cursor') {
|
|
673
|
+
// Cursor: flattened .md custom subagent under ~/.cursor/agents/
|
|
674
|
+
const agentsDir = path.join(agentHome, '.cursor', 'agents');
|
|
675
|
+
if (!fs.existsSync(agentsDir)) {
|
|
676
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
const transformed = transformSubagentForCursor(subagentDir);
|
|
680
|
+
fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.md`), transformed);
|
|
681
|
+
return { success: true };
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
return { success: false, error: String(err) };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
else if (agent === 'forge') {
|
|
688
|
+
// ForgeCode: flattened .md custom subagent under ~/.forge/agents/
|
|
689
|
+
const agentsDir = path.join(agentHome, '.forge', 'agents');
|
|
690
|
+
if (!fs.existsSync(agentsDir)) {
|
|
691
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
const transformed = transformSubagentForForge(subagentDir);
|
|
695
|
+
fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.md`), transformed);
|
|
696
|
+
return { success: true };
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
return { success: false, error: String(err) };
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
else if (agent === 'goose') {
|
|
703
|
+
// Goose: recipe YAML custom-agent file under ~/.config/goose/agents/
|
|
704
|
+
const agentsDir = path.join(agentHome, '.config', 'goose', 'agents');
|
|
705
|
+
if (!fs.existsSync(agentsDir)) {
|
|
706
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
const transformed = transformSubagentForGoose(subagentDir);
|
|
710
|
+
fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.yaml`), transformed);
|
|
711
|
+
return { success: true };
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
return { success: false, error: String(err) };
|
|
715
|
+
}
|
|
716
|
+
}
|
|
612
717
|
else {
|
|
613
718
|
// Other agents don't support subagents yet
|
|
614
719
|
return { success: false, error: `Agent '${agent}' does not support subagents` };
|
|
@@ -670,6 +775,27 @@ export function removeSubagentFromAgent(subagentName, agent, agentHome) {
|
|
|
670
775
|
}
|
|
671
776
|
return { success: true };
|
|
672
777
|
}
|
|
778
|
+
else if (agent === 'cursor') {
|
|
779
|
+
const targetPath = safeJoin(path.join(agentHome, '.cursor', 'agents'), `${subagentName}.md`);
|
|
780
|
+
if (fs.existsSync(targetPath)) {
|
|
781
|
+
fs.unlinkSync(targetPath);
|
|
782
|
+
}
|
|
783
|
+
return { success: true };
|
|
784
|
+
}
|
|
785
|
+
else if (agent === 'forge') {
|
|
786
|
+
const targetPath = safeJoin(path.join(agentHome, '.forge', 'agents'), `${subagentName}.md`);
|
|
787
|
+
if (fs.existsSync(targetPath)) {
|
|
788
|
+
fs.unlinkSync(targetPath);
|
|
789
|
+
}
|
|
790
|
+
return { success: true };
|
|
791
|
+
}
|
|
792
|
+
else if (agent === 'goose') {
|
|
793
|
+
const targetPath = safeJoin(path.join(agentHome, '.config', 'goose', 'agents'), `${subagentName}.yaml`);
|
|
794
|
+
if (fs.existsSync(targetPath)) {
|
|
795
|
+
fs.unlinkSync(targetPath);
|
|
796
|
+
}
|
|
797
|
+
return { success: true };
|
|
798
|
+
}
|
|
673
799
|
else {
|
|
674
800
|
return { success: true }; // No-op for unsupported agents
|
|
675
801
|
}
|
|
@@ -886,6 +1012,61 @@ export function listSubagentsForAgent(agentId, home) {
|
|
|
886
1012
|
});
|
|
887
1013
|
}
|
|
888
1014
|
}
|
|
1015
|
+
else if (agentId === 'cursor') {
|
|
1016
|
+
// Cursor: flat `<name>.md` files under ~/.cursor/agents/
|
|
1017
|
+
const agentsDir = path.join(home, '.cursor', 'agents');
|
|
1018
|
+
if (!fs.existsSync(agentsDir))
|
|
1019
|
+
return subagents;
|
|
1020
|
+
for (const file of fs.readdirSync(agentsDir)) {
|
|
1021
|
+
if (!file.endsWith('.md'))
|
|
1022
|
+
continue;
|
|
1023
|
+
const filePath = path.join(agentsDir, file);
|
|
1024
|
+
if (!fs.statSync(filePath).isFile())
|
|
1025
|
+
continue;
|
|
1026
|
+
const name = file.replace(/\.md$/, '');
|
|
1027
|
+
const frontmatter = parseSubagentFrontmatter(filePath) ?? { name, description: '' };
|
|
1028
|
+
subagents.push({ name, path: filePath, files: [file], frontmatter });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
else if (agentId === 'forge') {
|
|
1032
|
+
// ForgeCode: flat `<name>.md` files under ~/.forge/agents/
|
|
1033
|
+
const agentsDir = path.join(home, '.forge', 'agents');
|
|
1034
|
+
if (!fs.existsSync(agentsDir))
|
|
1035
|
+
return subagents;
|
|
1036
|
+
for (const file of fs.readdirSync(agentsDir)) {
|
|
1037
|
+
if (!file.endsWith('.md'))
|
|
1038
|
+
continue;
|
|
1039
|
+
const filePath = path.join(agentsDir, file);
|
|
1040
|
+
if (!fs.statSync(filePath).isFile())
|
|
1041
|
+
continue;
|
|
1042
|
+
const name = file.replace(/\.md$/, '');
|
|
1043
|
+
const frontmatter = parseSubagentFrontmatter(filePath) ?? { name, description: '' };
|
|
1044
|
+
subagents.push({ name, path: filePath, files: [file], frontmatter });
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
else if (agentId === 'goose') {
|
|
1048
|
+
// Goose: recipe YAML files under ~/.config/goose/agents/
|
|
1049
|
+
const agentsDir = path.join(home, '.config', 'goose', 'agents');
|
|
1050
|
+
if (!fs.existsSync(agentsDir))
|
|
1051
|
+
return subagents;
|
|
1052
|
+
for (const file of fs.readdirSync(agentsDir)) {
|
|
1053
|
+
if (!file.endsWith('.yaml'))
|
|
1054
|
+
continue;
|
|
1055
|
+
const filePath = path.join(agentsDir, file);
|
|
1056
|
+
if (!fs.statSync(filePath).isFile())
|
|
1057
|
+
continue;
|
|
1058
|
+
const name = file.replace(/\.yaml$/, '');
|
|
1059
|
+
let frontmatter;
|
|
1060
|
+
try {
|
|
1061
|
+
const recipe = yaml.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1062
|
+
frontmatter = { name: recipe?.title || name, description: recipe?.description || '' };
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
subagents.push({ name, path: filePath, files: [file], frontmatter });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
889
1070
|
return subagents;
|
|
890
1071
|
}
|
|
891
1072
|
/**
|
|
@@ -984,6 +1165,42 @@ export function diffVersionSubagents(agent, version) {
|
|
|
984
1165
|
}
|
|
985
1166
|
}
|
|
986
1167
|
}
|
|
1168
|
+
else if (agent === 'cursor') {
|
|
1169
|
+
const agentsDir = path.join(versionHome, '.cursor', 'agents');
|
|
1170
|
+
if (fs.existsSync(agentsDir)) {
|
|
1171
|
+
for (const file of fs.readdirSync(agentsDir)) {
|
|
1172
|
+
if (!file.endsWith('.md'))
|
|
1173
|
+
continue;
|
|
1174
|
+
const name = path.basename(file, '.md');
|
|
1175
|
+
if (!discovered.has(name))
|
|
1176
|
+
orphans.push(name);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
else if (agent === 'forge') {
|
|
1181
|
+
const agentsDir = path.join(versionHome, '.forge', 'agents');
|
|
1182
|
+
if (fs.existsSync(agentsDir)) {
|
|
1183
|
+
for (const file of fs.readdirSync(agentsDir)) {
|
|
1184
|
+
if (!file.endsWith('.md'))
|
|
1185
|
+
continue;
|
|
1186
|
+
const name = path.basename(file, '.md');
|
|
1187
|
+
if (!discovered.has(name))
|
|
1188
|
+
orphans.push(name);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
else if (agent === 'goose') {
|
|
1193
|
+
const agentsDir = path.join(versionHome, '.config', 'goose', 'agents');
|
|
1194
|
+
if (fs.existsSync(agentsDir)) {
|
|
1195
|
+
for (const file of fs.readdirSync(agentsDir)) {
|
|
1196
|
+
if (!file.endsWith('.yaml'))
|
|
1197
|
+
continue;
|
|
1198
|
+
const name = path.basename(file, '.yaml');
|
|
1199
|
+
if (!discovered.has(name))
|
|
1200
|
+
orphans.push(name);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
987
1204
|
return { agent, version, orphans: orphans.sort() };
|
|
988
1205
|
}
|
|
989
1206
|
/**
|
|
@@ -1071,6 +1288,27 @@ export function removeSubagentFromVersion(agent, version, subagentName) {
|
|
|
1071
1288
|
fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.json.${stamp}`));
|
|
1072
1289
|
}
|
|
1073
1290
|
}
|
|
1291
|
+
else if (agent === 'cursor') {
|
|
1292
|
+
const targetPath = path.join(versionHome, '.cursor', 'agents', `${subagentName}.md`);
|
|
1293
|
+
if (fs.existsSync(targetPath)) {
|
|
1294
|
+
fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
|
|
1295
|
+
fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.md.${stamp}`));
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
else if (agent === 'forge') {
|
|
1299
|
+
const targetPath = path.join(versionHome, '.forge', 'agents', `${subagentName}.md`);
|
|
1300
|
+
if (fs.existsSync(targetPath)) {
|
|
1301
|
+
fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
|
|
1302
|
+
fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.md.${stamp}`));
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
else if (agent === 'goose') {
|
|
1306
|
+
const targetPath = path.join(versionHome, '.config', 'goose', 'agents', `${subagentName}.yaml`);
|
|
1307
|
+
if (fs.existsSync(targetPath)) {
|
|
1308
|
+
fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
|
|
1309
|
+
fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.yaml.${stamp}`));
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1074
1312
|
return { success: true };
|
|
1075
1313
|
}
|
|
1076
1314
|
catch (err) {
|
|
@@ -146,19 +146,25 @@ export declare function setSessionHook(name: string, hook: string, command: stri
|
|
|
146
146
|
* in the tmux server instead of launching a second tmux client against
|
|
147
147
|
* the same socket from inside the hook. That self-client could race the
|
|
148
148
|
* server under load and leave the dead split behind.
|
|
149
|
+
* v5 — `run-shell -b -C "kill-pane -t #{hook_pane}"` runs the targeted kill-pane
|
|
150
|
+
* in the background inside the server command queue. The synchronous
|
|
151
|
+
* variant could stall the hook on a loaded CI runner, letting the dead
|
|
152
|
+
* split survive until the test's wait timeout expired (flake in CI shard
|
|
153
|
+
* 3: pane-guarded pane-died hook / user split exit).
|
|
149
154
|
*/
|
|
150
|
-
export declare const AGENT_HOOK_SCHEMA =
|
|
155
|
+
export declare const AGENT_HOOK_SCHEMA = 5;
|
|
151
156
|
/**
|
|
152
157
|
* The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
|
|
153
158
|
* (so the blocking attach in runInTmux returns and the exit status can be read);
|
|
154
159
|
* a user split's death runs the else-branch, closing just that split. The
|
|
155
|
-
* else-branch goes through `run-shell -C` with an explicit `-t #{hook_pane}`
|
|
160
|
+
* else-branch goes through `run-shell -b -C` with an explicit `-t #{hook_pane}`
|
|
156
161
|
* target: tmux format-expands the command at fire time and executes it inside
|
|
157
|
-
* the server command queue, so the event pane is always the
|
|
158
|
-
* launching a second tmux client against the same socket
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
+
* the server command queue in the background, so the event pane is always the
|
|
163
|
+
* one killed without launching a second tmux client against the same socket and
|
|
164
|
+
* without stalling the hook on a loaded runner. A bare `kill-pane` relied on
|
|
165
|
+
* the hook context supplying a "current pane", while an external self-client
|
|
166
|
+
* could race the server under load. Single source of truth: both the
|
|
167
|
+
* spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
|
|
162
168
|
* two can never drift.
|
|
163
169
|
*/
|
|
164
170
|
export declare function agentPaneDiedHook(sessionName: string, agentPane: string): string;
|
package/dist/lib/tmux/session.js
CHANGED
|
@@ -93,6 +93,15 @@ export async function createSession(opts) {
|
|
|
93
93
|
const res = await runTmux({ socket, args, env: opts.env });
|
|
94
94
|
// Only the new-session command in the `;`-chained invocation emits output.
|
|
95
95
|
const pane = /^%\d+$/.test(res.stdout.trim()) ? res.stdout.trim() : undefined;
|
|
96
|
+
// Keep the agent pane around after its process exits (so runInTmux can read
|
|
97
|
+
// the exit status and capture the final error), but do NOT keep that behavior
|
|
98
|
+
// for user-created splits. Apply remain-on-exit to the agent pane only, then
|
|
99
|
+
// revert the global default so future splits close automatically when their
|
|
100
|
+
// command finishes. The global-on above protects a fast-exiting agent during
|
|
101
|
+
// the brief window before the pane option is stamped.
|
|
102
|
+
if (pane) {
|
|
103
|
+
await runTmux({ socket, args: ['set-option', '-pt', pane, 'remain-on-exit', 'on', ';', 'set-option', '-g', 'remain-on-exit', 'off'], throwOnError: false }).catch(() => { });
|
|
104
|
+
}
|
|
96
105
|
const meta = {
|
|
97
106
|
name: opts.name,
|
|
98
107
|
socket,
|
|
@@ -294,25 +303,31 @@ export async function setSessionHook(name, hook, command, socket) {
|
|
|
294
303
|
* in the tmux server instead of launching a second tmux client against
|
|
295
304
|
* the same socket from inside the hook. That self-client could race the
|
|
296
305
|
* server under load and leave the dead split behind.
|
|
306
|
+
* v5 — `run-shell -b -C "kill-pane -t #{hook_pane}"` runs the targeted kill-pane
|
|
307
|
+
* in the background inside the server command queue. The synchronous
|
|
308
|
+
* variant could stall the hook on a loaded CI runner, letting the dead
|
|
309
|
+
* split survive until the test's wait timeout expired (flake in CI shard
|
|
310
|
+
* 3: pane-guarded pane-died hook / user split exit).
|
|
297
311
|
*/
|
|
298
|
-
export const AGENT_HOOK_SCHEMA =
|
|
312
|
+
export const AGENT_HOOK_SCHEMA = 5;
|
|
299
313
|
/** Per-session tmux user-option that records which AGENT_HOOK_SCHEMA a session's hook is at. */
|
|
300
314
|
const HOOK_SCHEMA_OPTION = '@ag_hook_schema';
|
|
301
315
|
/**
|
|
302
316
|
* The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
|
|
303
317
|
* (so the blocking attach in runInTmux returns and the exit status can be read);
|
|
304
318
|
* a user split's death runs the else-branch, closing just that split. The
|
|
305
|
-
* else-branch goes through `run-shell -C` with an explicit `-t #{hook_pane}`
|
|
319
|
+
* else-branch goes through `run-shell -b -C` with an explicit `-t #{hook_pane}`
|
|
306
320
|
* target: tmux format-expands the command at fire time and executes it inside
|
|
307
|
-
* the server command queue, so the event pane is always the
|
|
308
|
-
* launching a second tmux client against the same socket
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
321
|
+
* the server command queue in the background, so the event pane is always the
|
|
322
|
+
* one killed without launching a second tmux client against the same socket and
|
|
323
|
+
* without stalling the hook on a loaded runner. A bare `kill-pane` relied on
|
|
324
|
+
* the hook context supplying a "current pane", while an external self-client
|
|
325
|
+
* could race the server under load. Single source of truth: both the
|
|
326
|
+
* spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
|
|
312
327
|
* two can never drift.
|
|
313
328
|
*/
|
|
314
329
|
export function agentPaneDiedHook(sessionName, agentPane) {
|
|
315
|
-
return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'run-shell -C "kill-pane -t #{hook_pane}"'`;
|
|
330
|
+
return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'run-shell -b -C "kill-pane -t #{hook_pane}"'`;
|
|
316
331
|
}
|
|
317
332
|
/** Stamp a session's hook-schema marker to the current version. */
|
|
318
333
|
export async function markSessionHookSchema(name, socket) {
|
package/dist/lib/versions.js
CHANGED
|
@@ -1148,6 +1148,72 @@ export function setGlobalDefault(agent, version) {
|
|
|
1148
1148
|
}
|
|
1149
1149
|
writeMeta(meta);
|
|
1150
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Grok's official installer writes into ~/.grok/downloads, which (because we
|
|
1153
|
+
* symlink ~/.grok to the active version home) resolves to the PREVIOUS default
|
|
1154
|
+
* home during `agents add grok@<new>`. Move the freshly-downloaded binary and
|
|
1155
|
+
* its generic platform copy into the target version's isolated home so
|
|
1156
|
+
* `listInstalledVersions` and the shim resolve the right binary.
|
|
1157
|
+
*/
|
|
1158
|
+
function relocateGrokBinaryToVersionHome(installedVersion) {
|
|
1159
|
+
const hostGrokLink = path.join(getHomeDir(), agentConfigDirName('grok'));
|
|
1160
|
+
let sourceDownloads;
|
|
1161
|
+
try {
|
|
1162
|
+
sourceDownloads = path.join(fs.readlinkSync(hostGrokLink), 'downloads');
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
sourceDownloads = path.join(hostGrokLink, 'downloads');
|
|
1166
|
+
}
|
|
1167
|
+
const targetDownloads = path.join(getVersionHomePath('grok', installedVersion), agentConfigDirName('grok'), 'downloads');
|
|
1168
|
+
if (!fs.existsSync(sourceDownloads))
|
|
1169
|
+
return;
|
|
1170
|
+
if (path.resolve(sourceDownloads) === path.resolve(targetDownloads))
|
|
1171
|
+
return;
|
|
1172
|
+
const entries = fs.readdirSync(sourceDownloads).filter((e) => e.startsWith('grok-'));
|
|
1173
|
+
if (entries.length === 0)
|
|
1174
|
+
return;
|
|
1175
|
+
fs.mkdirSync(targetDownloads, { recursive: true });
|
|
1176
|
+
const escapedVersion = installedVersion.replace(/\./g, '\\.');
|
|
1177
|
+
const versionedPattern = new RegExp(`^grok-${escapedVersion}-`);
|
|
1178
|
+
const movedPaths = [];
|
|
1179
|
+
// Move the versioned binary first.
|
|
1180
|
+
for (const entry of entries) {
|
|
1181
|
+
if (!versionedPattern.test(entry))
|
|
1182
|
+
continue;
|
|
1183
|
+
const src = path.join(sourceDownloads, entry);
|
|
1184
|
+
const dst = path.join(targetDownloads, entry);
|
|
1185
|
+
if (fs.existsSync(dst))
|
|
1186
|
+
continue;
|
|
1187
|
+
try {
|
|
1188
|
+
fs.renameSync(src, dst);
|
|
1189
|
+
movedPaths.push(dst);
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
/* ignore per-file failures */
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (movedPaths.length === 0)
|
|
1196
|
+
return;
|
|
1197
|
+
// The installer also creates a generic platform binary (e.g. grok-macos-aarch64)
|
|
1198
|
+
// that is a copy of the versioned binary. Move it too if its size matches.
|
|
1199
|
+
const movedSize = fs.statSync(movedPaths[0]).size;
|
|
1200
|
+
for (const entry of entries) {
|
|
1201
|
+
if (versionedPattern.test(entry))
|
|
1202
|
+
continue; // already handled
|
|
1203
|
+
const src = path.join(sourceDownloads, entry);
|
|
1204
|
+
const dst = path.join(targetDownloads, entry);
|
|
1205
|
+
if (fs.existsSync(dst))
|
|
1206
|
+
continue;
|
|
1207
|
+
try {
|
|
1208
|
+
if (fs.statSync(src).size === movedSize) {
|
|
1209
|
+
fs.renameSync(src, dst);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
catch {
|
|
1213
|
+
/* ignore per-file failures */
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1151
1217
|
/**
|
|
1152
1218
|
* Install a specific version of an agent.
|
|
1153
1219
|
*/
|
|
@@ -1204,6 +1270,12 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1204
1270
|
const versionDir = getVersionDir(agent, installedVersion);
|
|
1205
1271
|
fs.mkdirSync(versionDir, { recursive: true });
|
|
1206
1272
|
fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
|
|
1273
|
+
// Grok's installer drops the binary into ~/.grok/downloads, which currently
|
|
1274
|
+
// resolves to the PREVIOUS default home. Move it into the target version home
|
|
1275
|
+
// so version isolation is correct.
|
|
1276
|
+
if (agent === 'grok') {
|
|
1277
|
+
relocateGrokBinaryToVersionHome(installedVersion);
|
|
1278
|
+
}
|
|
1207
1279
|
// Symlink the installed binary into the version's node_modules/.bin so
|
|
1208
1280
|
// listInstalledVersions (which checks getBinaryPath) sees this version as
|
|
1209
1281
|
// installed. Without this, `agents add antigravity@latest` succeeds
|
package/dist/lib/workflows.d.ts
CHANGED
|
@@ -265,6 +265,15 @@ export declare function pruneStaleWorkflowSubagents(sharedAgentsDir: string, wor
|
|
|
265
265
|
export declare function countWorkflowSubagents(workflowDir: string): number;
|
|
266
266
|
/** Convert a canonical agents-cli workflow bundle into a Kimi flow skill. */
|
|
267
267
|
export declare function transformWorkflowForKimi(workflowPath: string, name: string): string;
|
|
268
|
+
/**
|
|
269
|
+
* Convert a canonical agents-cli workflow bundle into an Antigravity workflow
|
|
270
|
+
* markdown file. Antigravity discovers workflows as flat `<name>.md` files under
|
|
271
|
+
* `~/.gemini/config/global_workflows/` (scanned by `agy` at startup) and exposes
|
|
272
|
+
* each as a `/<name>` slash command. Frontmatter carries the required `description`
|
|
273
|
+
* plus the shared `agents_workflow` ownership marker so agents-cli never clobbers a
|
|
274
|
+
* user-authored workflow of the same name.
|
|
275
|
+
*/
|
|
276
|
+
export declare function transformWorkflowForAntigravity(workflowPath: string, name: string): string;
|
|
268
277
|
/**
|
|
269
278
|
* Resolve an `agents run <workflow>` reference.
|
|
270
279
|
*
|
package/dist/lib/workflows.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* are composed at runtime by `agents run <workflow>`.
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
9
10
|
import * as path from 'path';
|
|
10
11
|
import * as yaml from 'yaml';
|
|
11
12
|
import { capableAgents } from './capabilities.js';
|
|
@@ -369,6 +370,26 @@ export function transformWorkflowForKimi(workflowPath, name) {
|
|
|
369
370
|
const instructions = (body || fm.description).trim();
|
|
370
371
|
return `---\n${frontmatter}\n---\n\n\`\`\`d2\nBEGIN -> step -> END\nstep: |md\n${indentD2BlockString(instructions)}\n|\n\`\`\`\n`;
|
|
371
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* Convert a canonical agents-cli workflow bundle into an Antigravity workflow
|
|
375
|
+
* markdown file. Antigravity discovers workflows as flat `<name>.md` files under
|
|
376
|
+
* `~/.gemini/config/global_workflows/` (scanned by `agy` at startup) and exposes
|
|
377
|
+
* each as a `/<name>` slash command. Frontmatter carries the required `description`
|
|
378
|
+
* plus the shared `agents_workflow` ownership marker so agents-cli never clobbers a
|
|
379
|
+
* user-authored workflow of the same name.
|
|
380
|
+
*/
|
|
381
|
+
export function transformWorkflowForAntigravity(workflowPath, name) {
|
|
382
|
+
const fm = parseWorkflowFrontmatter(workflowPath);
|
|
383
|
+
if (!fm)
|
|
384
|
+
throw new Error(`Invalid WORKFLOW.md in ${workflowPath}`);
|
|
385
|
+
const body = getWorkflowBody(workflowPath) || fm.description;
|
|
386
|
+
const frontmatter = yaml.stringify({
|
|
387
|
+
description: fm.description,
|
|
388
|
+
name: fm.name || name,
|
|
389
|
+
[KIMI_WORKFLOW_MARKER]: name,
|
|
390
|
+
}).trim();
|
|
391
|
+
return `---\n${frontmatter}\n---\n\n${body.trim()}\n`;
|
|
392
|
+
}
|
|
372
393
|
function expandWorkflowPath(ref) {
|
|
373
394
|
if (ref === '~')
|
|
374
395
|
return process.env.HOME ?? ref;
|
|
@@ -527,9 +548,24 @@ export function removeWorkflow(name) {
|
|
|
527
548
|
return { success: false, error: err.message };
|
|
528
549
|
}
|
|
529
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* Antigravity user workflows are NOT version-isolated. `agy` scans a single,
|
|
553
|
+
* shared, HOME-global directory at startup — `~/.gemini/config/global_workflows/`
|
|
554
|
+
* — and that dir is a real directory in the user's home, never symlinked into a
|
|
555
|
+
* per-version home (only `~/.gemini/antigravity-cli` is version-scoped). Writing
|
|
556
|
+
* into a version home therefore lands somewhere agy never reads. So every
|
|
557
|
+
* antigravity version resolves to the same real shared dir; `versionHome` is
|
|
558
|
+
* intentionally ignored. (Verified via strace of `agy`: it opens
|
|
559
|
+
* `$HOME/.gemini/config/global_workflows/<name>.md` and never the version home.)
|
|
560
|
+
*/
|
|
561
|
+
function antigravityWorkflowsDir() {
|
|
562
|
+
return path.join(process.env.HOME ?? os.homedir(), '.gemini', 'config', 'global_workflows');
|
|
563
|
+
}
|
|
530
564
|
function workflowTargetRoot(agent, versionHome) {
|
|
531
565
|
if (agent === 'kimi')
|
|
532
566
|
return path.join(versionHome, '.kimi-code', 'skills');
|
|
567
|
+
if (agent === 'antigravity')
|
|
568
|
+
return antigravityWorkflowsDir();
|
|
533
569
|
return path.join(versionHome, 'workflows');
|
|
534
570
|
}
|
|
535
571
|
/** List workflow names synced into a specific agent version home. */
|
|
@@ -543,6 +579,20 @@ export function listWorkflowsForAgent(agent, versionHome) {
|
|
|
543
579
|
.filter(d => kimiWorkflowMarker(path.join(skillsDir, d.name, 'SKILL.md')) === d.name)
|
|
544
580
|
.map(d => d.name);
|
|
545
581
|
}
|
|
582
|
+
if (agent === 'antigravity') {
|
|
583
|
+
const dir = workflowTargetRoot(agent, versionHome);
|
|
584
|
+
if (!fs.existsSync(dir))
|
|
585
|
+
return [];
|
|
586
|
+
try {
|
|
587
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
588
|
+
.filter(d => d.isFile() && d.name.endsWith('.md') && !d.name.startsWith('.'))
|
|
589
|
+
.map(d => d.name.slice(0, -'.md'.length))
|
|
590
|
+
.filter(base => antigravityWorkflowMarker(path.join(dir, `${base}.md`)) === base);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
546
596
|
if (agent === 'goose') {
|
|
547
597
|
const recipesDir = path.join(versionHome, '.config', 'goose', 'recipes');
|
|
548
598
|
if (!fs.existsSync(recipesDir))
|
|
@@ -678,7 +728,17 @@ export function syncWorkflowToVersion(workflowPath, name, agent, versionHome) {
|
|
|
678
728
|
return { success: true };
|
|
679
729
|
}
|
|
680
730
|
if (agent === 'antigravity') {
|
|
681
|
-
|
|
731
|
+
const targetDir = workflowTargetRoot(agent, versionHome);
|
|
732
|
+
const targetFile = path.join(targetDir, `${name}.md`);
|
|
733
|
+
if (fs.existsSync(targetFile)) {
|
|
734
|
+
const marker = antigravityWorkflowMarker(targetFile);
|
|
735
|
+
if (marker !== name) {
|
|
736
|
+
return { success: false, error: `Antigravity workflow '${name}' already exists and is not managed by agents-cli` };
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
740
|
+
fs.writeFileSync(targetFile, transformWorkflowForAntigravity(workflowPath, name), 'utf-8');
|
|
741
|
+
return { success: true };
|
|
682
742
|
}
|
|
683
743
|
const targetDir = path.join(workflowTargetRoot(agent, versionHome), name);
|
|
684
744
|
fs.mkdirSync(workflowTargetRoot(agent, versionHome), { recursive: true });
|
|
@@ -696,7 +756,20 @@ export function syncWorkflowToVersion(workflowPath, name, agent, versionHome) {
|
|
|
696
756
|
export function removeWorkflowFromVersion(agent, version, name) {
|
|
697
757
|
const versionHome = getVersionHomePath(agent, version);
|
|
698
758
|
if (agent === 'antigravity') {
|
|
699
|
-
|
|
759
|
+
const targetFile = path.join(workflowTargetRoot(agent, versionHome), `${name}.md`);
|
|
760
|
+
if (!fs.existsSync(targetFile)) {
|
|
761
|
+
return { success: false, error: `Workflow '${name}' not synced to ${agent}@${version}` };
|
|
762
|
+
}
|
|
763
|
+
if (antigravityWorkflowMarker(targetFile) !== name) {
|
|
764
|
+
return { success: false, error: `Antigravity workflow '${name}' is not managed by agents-cli` };
|
|
765
|
+
}
|
|
766
|
+
try {
|
|
767
|
+
fs.rmSync(targetFile, { force: true });
|
|
768
|
+
return { success: true };
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
return { success: false, error: err.message };
|
|
772
|
+
}
|
|
700
773
|
}
|
|
701
774
|
if (agent === 'goose') {
|
|
702
775
|
const recipePath = path.join(versionHome, '.config', 'goose', 'recipes', `${name}.yaml`);
|
|
@@ -754,6 +827,15 @@ function kimiWorkflowMarker(filePath) {
|
|
|
754
827
|
return null;
|
|
755
828
|
}
|
|
756
829
|
}
|
|
830
|
+
function antigravityWorkflowMarker(filePath) {
|
|
831
|
+
try {
|
|
832
|
+
const fm = parseSkillFrontmatter(filePath);
|
|
833
|
+
return typeof fm?.agents_workflow === 'string' ? fm.agents_workflow : null;
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
757
839
|
/** Iterate all installed (agent, version) pairs that support workflows. */
|
|
758
840
|
export function iterWorkflowsCapableVersions(filter) {
|
|
759
841
|
const result = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.62",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|