@f5-sales-demo/xcsh 21.40.0 → 21.41.0
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/package.json +8 -8
- package/src/capability/fs.ts +20 -10
- package/src/capability/index.ts +5 -2
- package/src/capability/types.ts +4 -0
- package/src/discovery/agents-md.ts +2 -1
- package/src/discovery/agents.ts +4 -4
- package/src/discovery/builtin.ts +6 -4
- package/src/discovery/claude-plugins.ts +2 -1
- package/src/discovery/claude.ts +5 -4
- package/src/discovery/codex.ts +3 -1
- package/src/discovery/gemini.ts +4 -4
- package/src/discovery/github.ts +1 -1
- package/src/discovery/helpers.ts +110 -17
- package/src/discovery/opencode.ts +3 -1
- package/src/extensibility/skills.ts +12 -3
- package/src/integrations/registry.ts +4 -2
- package/src/internal-urls/api-catalog-preflight.ts +210 -0
- package/src/internal-urls/api-spec-resolve.ts +156 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/print-mode.ts +8 -4
- package/src/prompts/rules/llms-search.md +2 -1
- package/src/prompts/system/system-prompt.md +12 -3
- package/src/sdk.ts +73 -43
- package/src/session/agent-session.ts +54 -1
- package/src/system-prompt.ts +469 -162
- package/src/task/executor.ts +5 -7
- package/src/task/index.ts +1 -0
- package/src/tools/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "21.
|
|
4
|
+
"version": "21.41.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -64,13 +64,13 @@
|
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
67
|
-
"@f5-sales-demo/pi-agent-core": "21.
|
|
68
|
-
"@f5-sales-demo/pi-ai": "21.
|
|
69
|
-
"@f5-sales-demo/pi-natives": "21.
|
|
70
|
-
"@f5-sales-demo/pi-resource-management": "21.
|
|
71
|
-
"@f5-sales-demo/pi-tui": "21.
|
|
72
|
-
"@f5-sales-demo/pi-utils": "21.
|
|
73
|
-
"@f5-sales-demo/xcsh-stats": "21.
|
|
67
|
+
"@f5-sales-demo/pi-agent-core": "21.41.0",
|
|
68
|
+
"@f5-sales-demo/pi-ai": "21.41.0",
|
|
69
|
+
"@f5-sales-demo/pi-natives": "21.41.0",
|
|
70
|
+
"@f5-sales-demo/pi-resource-management": "21.41.0",
|
|
71
|
+
"@f5-sales-demo/pi-tui": "21.41.0",
|
|
72
|
+
"@f5-sales-demo/pi-utils": "21.41.0",
|
|
73
|
+
"@f5-sales-demo/xcsh-stats": "21.41.0",
|
|
74
74
|
"@mozilla/readability": "^0.6",
|
|
75
75
|
"@sinclair/typebox": "0.34.52",
|
|
76
76
|
"@tobilu/qmd": "2.8.3",
|
package/src/capability/fs.ts
CHANGED
|
@@ -8,40 +8,47 @@ function resolvePath(filePath: string): string {
|
|
|
8
8
|
return path.resolve(filePath);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export async function readFile(filePath: string): Promise<string | null> {
|
|
11
|
+
export async function readFile(filePath: string, signal?: AbortSignal): Promise<string | null> {
|
|
12
12
|
const abs = resolvePath(filePath);
|
|
13
13
|
if (contentCache.has(abs)) {
|
|
14
14
|
return contentCache.get(abs) ?? null;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
try {
|
|
18
|
-
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
const stat = await fs.promises.stat(abs);
|
|
20
|
+
if (!stat.isFile()) return null;
|
|
21
|
+
const content = await fs.promises.readFile(abs, { encoding: "utf8", signal });
|
|
19
22
|
contentCache.set(abs, content);
|
|
20
23
|
return content;
|
|
21
|
-
} catch {
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (signal?.aborted) throw error;
|
|
22
26
|
contentCache.set(abs, null);
|
|
23
27
|
return null;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
export async function readDirEntries(dirPath: string): Promise<fs.Dirent[]> {
|
|
31
|
+
export async function readDirEntries(dirPath: string, signal?: AbortSignal): Promise<fs.Dirent[]> {
|
|
28
32
|
const abs = resolvePath(dirPath);
|
|
29
33
|
if (dirCache.has(abs)) {
|
|
30
34
|
return dirCache.get(abs) ?? [];
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
try {
|
|
38
|
+
signal?.throwIfAborted();
|
|
34
39
|
const entries = await fs.promises.readdir(abs, { withFileTypes: true });
|
|
40
|
+
signal?.throwIfAborted();
|
|
35
41
|
dirCache.set(abs, entries);
|
|
36
42
|
return entries;
|
|
37
|
-
} catch {
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (signal?.aborted) throw error;
|
|
38
45
|
dirCache.set(abs, []);
|
|
39
46
|
return [];
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
export async function readDir(dirPath: string): Promise<string[]> {
|
|
44
|
-
const entries = await readDirEntries(dirPath);
|
|
50
|
+
export async function readDir(dirPath: string, signal?: AbortSignal): Promise<string[]> {
|
|
51
|
+
const entries = await readDirEntries(dirPath, signal);
|
|
45
52
|
return entries.map(entry => entry.name);
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -49,12 +56,14 @@ export async function walkUp(
|
|
|
49
56
|
startDir: string,
|
|
50
57
|
name: string,
|
|
51
58
|
opts: { file?: boolean; dir?: boolean } = {},
|
|
59
|
+
signal?: AbortSignal,
|
|
52
60
|
): Promise<string | null> {
|
|
53
61
|
const { file = true, dir = true } = opts;
|
|
54
62
|
let current = resolvePath(startDir);
|
|
55
63
|
|
|
56
64
|
while (true) {
|
|
57
|
-
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
const entries = await readDirEntries(current, signal);
|
|
58
67
|
const entry = entries.find(e => e.name === name);
|
|
59
68
|
if (entry) {
|
|
60
69
|
if (file && entry.isFile()) return path.join(current, name);
|
|
@@ -71,10 +80,11 @@ export async function walkUp(
|
|
|
71
80
|
* Returns the directory containing `.git` (the repo root), or null if not in a git repo.
|
|
72
81
|
* Results are based on the cached readDirEntries, so repeated calls are cheap.
|
|
73
82
|
*/
|
|
74
|
-
export async function findRepoRoot(startDir: string): Promise<string | null> {
|
|
83
|
+
export async function findRepoRoot(startDir: string, signal?: AbortSignal): Promise<string | null> {
|
|
75
84
|
let current = resolvePath(startDir);
|
|
76
85
|
while (true) {
|
|
77
|
-
|
|
86
|
+
signal?.throwIfAborted();
|
|
87
|
+
const entries = await readDirEntries(current, signal);
|
|
78
88
|
if (entries.some(e => e.name === ".git")) {
|
|
79
89
|
return current;
|
|
80
90
|
}
|
package/src/capability/index.ts
CHANGED
|
@@ -114,6 +114,7 @@ async function loadImpl<T>(
|
|
|
114
114
|
const results = await Promise.all(
|
|
115
115
|
providers.map(async provider => {
|
|
116
116
|
try {
|
|
117
|
+
ctx.signal?.throwIfAborted();
|
|
117
118
|
const result = await logger.time(
|
|
118
119
|
`capability:${capability.id}:${provider.id}`,
|
|
119
120
|
provider.load.bind(provider),
|
|
@@ -121,6 +122,7 @@ async function loadImpl<T>(
|
|
|
121
122
|
);
|
|
122
123
|
return { provider, result };
|
|
123
124
|
} catch (error) {
|
|
125
|
+
if (ctx.signal?.aborted) throw error;
|
|
124
126
|
logger.debug(`capability:${capability.id}:${provider.id}:error`);
|
|
125
127
|
return { provider, error };
|
|
126
128
|
}
|
|
@@ -235,8 +237,9 @@ export async function loadCapability<T>(capabilityId: string, options: LoadOptio
|
|
|
235
237
|
|
|
236
238
|
const cwd = options.cwd ?? getProjectDir();
|
|
237
239
|
const home = os.homedir();
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
+
options.signal?.throwIfAborted();
|
|
241
|
+
const repoRoot = await findRepoRoot(cwd, options.signal);
|
|
242
|
+
const ctx: LoadContext = { cwd, home, repoRoot, signal: options.signal };
|
|
240
243
|
const providers = filterProviders(capability, options);
|
|
241
244
|
|
|
242
245
|
return await loadImpl(capability, providers, ctx, options);
|
package/src/capability/types.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface LoadContext {
|
|
|
16
16
|
home: string;
|
|
17
17
|
/** Git repository root (directory containing .git), or null if not in a repo */
|
|
18
18
|
repoRoot: string | null;
|
|
19
|
+
/** Cancellation for bounded discovery. */
|
|
20
|
+
signal?: AbortSignal;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -72,6 +74,8 @@ export interface LoadOptions {
|
|
|
72
74
|
includeDisabled?: boolean;
|
|
73
75
|
/** Explicit disabled extension IDs to apply instead of settings. */
|
|
74
76
|
disabledExtensions?: string[];
|
|
77
|
+
/** Cancellation for bounded discovery. */
|
|
78
|
+
signal?: AbortSignal;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
/**
|
|
@@ -26,8 +26,9 @@ async function loadAgentsMd(ctx: LoadContext): Promise<LoadResult<ContextFile>>
|
|
|
26
26
|
let current = ctx.cwd;
|
|
27
27
|
|
|
28
28
|
while (true) {
|
|
29
|
+
ctx.signal?.throwIfAborted();
|
|
29
30
|
const candidate = path.join(current, "XCSH.md");
|
|
30
|
-
const content = await readFile(candidate);
|
|
31
|
+
const content = await readFile(candidate, ctx.signal);
|
|
31
32
|
|
|
32
33
|
if (content !== null) {
|
|
33
34
|
const parent = path.dirname(candidate);
|
package/src/discovery/agents.ts
CHANGED
|
@@ -52,10 +52,10 @@ function getProjectPathCandidates(ctx: LoadContext, ...segments: string[]): stri
|
|
|
52
52
|
// Skills
|
|
53
53
|
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
54
54
|
const projectScans = getProjectPathCandidates(ctx, "skills").map(dir =>
|
|
55
|
-
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }),
|
|
55
|
+
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project", signal: ctx.signal }),
|
|
56
56
|
);
|
|
57
57
|
const userScans = getUserPathCandidates(ctx, "skills").map(dir =>
|
|
58
|
-
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "user" }),
|
|
58
|
+
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "user", signal: ctx.signal }),
|
|
59
59
|
);
|
|
60
60
|
|
|
61
61
|
const results = await Promise.all([...projectScans, ...userScans]);
|
|
@@ -170,7 +170,7 @@ registerProvider<SlashCommand>(slashCommandCapability.id, {
|
|
|
170
170
|
// Context Files (AGENTS.md)
|
|
171
171
|
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
|
|
172
172
|
const load = async (filePath: string, level: "user" | "project"): Promise<ContextFile | null> => {
|
|
173
|
-
const content = await readFile(filePath);
|
|
173
|
+
const content = await readFile(filePath, ctx.signal);
|
|
174
174
|
if (!content) return null;
|
|
175
175
|
// filePath is <ancestor>/.agent(s)/AGENTS.md — go up past the config dir to the ancestor
|
|
176
176
|
const ancestorDir = path.dirname(path.dirname(filePath));
|
|
@@ -197,7 +197,7 @@ registerProvider<ContextFile>(contextFileCapability.id, {
|
|
|
197
197
|
// System Prompt (SYSTEM.md)
|
|
198
198
|
async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
|
|
199
199
|
const load = async (filePath: string, level: "user" | "project"): Promise<SystemPrompt | null> => {
|
|
200
|
-
const content = await readFile(filePath);
|
|
200
|
+
const content = await readFile(filePath, ctx.signal);
|
|
201
201
|
if (!content) return null;
|
|
202
202
|
return { path: filePath, content, level, _source: createSourceMeta(PROVIDER_ID, filePath, level) };
|
|
203
203
|
};
|
package/src/discovery/builtin.ts
CHANGED
|
@@ -225,7 +225,7 @@ async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemProm
|
|
|
225
225
|
const items: SystemPrompt[] = [];
|
|
226
226
|
|
|
227
227
|
const userPath = path.join(ctx.home, PATHS.userAgent, "SYSTEM.md");
|
|
228
|
-
const userContent = await readFile(userPath);
|
|
228
|
+
const userContent = await readFile(userPath, ctx.signal);
|
|
229
229
|
if (userContent) {
|
|
230
230
|
items.push({
|
|
231
231
|
path: userPath,
|
|
@@ -238,7 +238,7 @@ async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemProm
|
|
|
238
238
|
const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
|
|
239
239
|
if (nearestProjectConfigDir) {
|
|
240
240
|
const projectPath = path.join(nearestProjectConfigDir.dir, "SYSTEM.md");
|
|
241
|
-
const projectContent = await readFile(projectPath);
|
|
241
|
+
const projectContent = await readFile(projectPath, ctx.signal);
|
|
242
242
|
if (projectContent) {
|
|
243
243
|
items.push({
|
|
244
244
|
path: projectPath,
|
|
@@ -270,6 +270,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
270
270
|
providerId: PROVIDER_ID,
|
|
271
271
|
level: "project",
|
|
272
272
|
requireDescription: true,
|
|
273
|
+
signal: ctx.signal,
|
|
273
274
|
}),
|
|
274
275
|
);
|
|
275
276
|
|
|
@@ -279,6 +280,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
279
280
|
providerId: PROVIDER_ID,
|
|
280
281
|
level: "user",
|
|
281
282
|
requireDescription: true,
|
|
283
|
+
signal: ctx.signal,
|
|
282
284
|
});
|
|
283
285
|
|
|
284
286
|
const results = await Promise.all([...projectScans, userScan]);
|
|
@@ -814,7 +816,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
|
|
814
816
|
const warnings: string[] = [];
|
|
815
817
|
|
|
816
818
|
const userPath = path.join(ctx.home, PATHS.userAgent, "XCSH.md");
|
|
817
|
-
const userContent = await readFile(userPath);
|
|
819
|
+
const userContent = await readFile(userPath, ctx.signal);
|
|
818
820
|
if (userContent) {
|
|
819
821
|
items.push({
|
|
820
822
|
path: userPath,
|
|
@@ -827,7 +829,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
|
|
827
829
|
const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
|
|
828
830
|
if (nearestProjectConfigDir) {
|
|
829
831
|
const projectPath = path.join(nearestProjectConfigDir.dir, "XCSH.md");
|
|
830
|
-
const projectContent = await readFile(projectPath);
|
|
832
|
+
const projectContent = await readFile(projectPath, ctx.signal);
|
|
831
833
|
if (projectContent) {
|
|
832
834
|
items.push({
|
|
833
835
|
path: projectPath,
|
|
@@ -37,7 +37,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
37
37
|
const items: Skill[] = [];
|
|
38
38
|
const warnings: string[] = [];
|
|
39
39
|
|
|
40
|
-
const { roots, warnings: rootWarnings } = await listXcshPluginRoots(ctx.home, ctx.cwd);
|
|
40
|
+
const { roots, warnings: rootWarnings } = await listXcshPluginRoots(ctx.home, ctx.cwd, ctx.signal);
|
|
41
41
|
warnings.push(...rootWarnings);
|
|
42
42
|
|
|
43
43
|
const results = await Promise.all(
|
|
@@ -47,6 +47,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
47
47
|
dir: skillsDir,
|
|
48
48
|
providerId: PROVIDER_ID,
|
|
49
49
|
level: scopeToLevel(root.scope),
|
|
50
|
+
signal: ctx.signal,
|
|
50
51
|
});
|
|
51
52
|
return { root, result };
|
|
52
53
|
}),
|
package/src/discovery/claude.ts
CHANGED
|
@@ -132,7 +132,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
|
|
132
132
|
const userBase = getUserClaude(ctx);
|
|
133
133
|
const userClaudeMd = path.join(userBase, "CLAUDE.md");
|
|
134
134
|
|
|
135
|
-
const userContent = await readFile(userClaudeMd);
|
|
135
|
+
const userContent = await readFile(userClaudeMd, ctx.signal);
|
|
136
136
|
if (userContent !== null) {
|
|
137
137
|
items.push({
|
|
138
138
|
path: userClaudeMd,
|
|
@@ -144,7 +144,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
|
|
144
144
|
|
|
145
145
|
const projectBase = getProjectClaude(ctx);
|
|
146
146
|
const projectClaudeMd = path.join(projectBase, "CLAUDE.md");
|
|
147
|
-
const projectContent = await readFile(projectClaudeMd);
|
|
147
|
+
const projectContent = await readFile(projectClaudeMd, ctx.signal);
|
|
148
148
|
if (projectContent !== null) {
|
|
149
149
|
const depth = calculateDepth(ctx.cwd, path.dirname(projectBase), path.sep);
|
|
150
150
|
items.push({
|
|
@@ -175,6 +175,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
175
175
|
dir: path.join(current, CONFIG_DIR, "skills"),
|
|
176
176
|
providerId: PROVIDER_ID,
|
|
177
177
|
level: "project",
|
|
178
|
+
signal: ctx.signal,
|
|
178
179
|
}),
|
|
179
180
|
);
|
|
180
181
|
if (current === (ctx.repoRoot ?? ctx.home)) break;
|
|
@@ -184,7 +185,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
const [userResult, ...projectResults] = await Promise.allSettled([
|
|
187
|
-
scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }),
|
|
188
|
+
scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user", signal: ctx.signal }),
|
|
188
189
|
...projectScans,
|
|
189
190
|
]);
|
|
190
191
|
|
|
@@ -407,7 +408,7 @@ async function loadSystemPrompts(ctx: LoadContext): Promise<LoadResult<SystemPro
|
|
|
407
408
|
const userBase = getUserClaude(ctx);
|
|
408
409
|
const userSystemMd = path.join(userBase, "SYSTEM.md");
|
|
409
410
|
|
|
410
|
-
const content = await readFile(userSystemMd);
|
|
411
|
+
const content = await readFile(userSystemMd, ctx.signal);
|
|
411
412
|
if (content !== null) {
|
|
412
413
|
items.push({
|
|
413
414
|
path: userSystemMd,
|
package/src/discovery/codex.ts
CHANGED
|
@@ -56,7 +56,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
|
|
56
56
|
|
|
57
57
|
// User level only: ~/.codex/AGENTS.md
|
|
58
58
|
const agentsMd = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "AGENTS.md");
|
|
59
|
-
const agentsContent = await readFile(agentsMd);
|
|
59
|
+
const agentsContent = await readFile(agentsMd, ctx.signal);
|
|
60
60
|
if (agentsContent) {
|
|
61
61
|
items.push({
|
|
62
62
|
path: agentsMd,
|
|
@@ -221,11 +221,13 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
221
221
|
dir: userSkillsDir,
|
|
222
222
|
providerId: PROVIDER_ID,
|
|
223
223
|
level: "user",
|
|
224
|
+
signal: ctx.signal,
|
|
224
225
|
}),
|
|
225
226
|
scanSkillsFromDir(ctx, {
|
|
226
227
|
dir: projectSkillsDir,
|
|
227
228
|
providerId: PROVIDER_ID,
|
|
228
229
|
level: "project",
|
|
230
|
+
signal: ctx.signal,
|
|
229
231
|
}),
|
|
230
232
|
]);
|
|
231
233
|
|
package/src/discovery/gemini.ts
CHANGED
|
@@ -129,7 +129,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
|
|
129
129
|
// User-level: ~/.gemini/GEMINI.md
|
|
130
130
|
const userGeminiMd = getUserPath(ctx, "gemini", "GEMINI.md");
|
|
131
131
|
if (userGeminiMd) {
|
|
132
|
-
const content = await readFile(userGeminiMd);
|
|
132
|
+
const content = await readFile(userGeminiMd, ctx.signal);
|
|
133
133
|
if (content) {
|
|
134
134
|
items.push({
|
|
135
135
|
path: userGeminiMd,
|
|
@@ -143,7 +143,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
|
|
143
143
|
// Project-level: .gemini/GEMINI.md
|
|
144
144
|
const projectGeminiMd = getProjectPath(ctx, "gemini", "GEMINI.md");
|
|
145
145
|
if (projectGeminiMd) {
|
|
146
|
-
const content = await readFile(projectGeminiMd);
|
|
146
|
+
const content = await readFile(projectGeminiMd, ctx.signal);
|
|
147
147
|
if (content) {
|
|
148
148
|
const projectBase = getProjectPath(ctx, "gemini", "");
|
|
149
149
|
const depth = projectBase ? calculateDepth(ctx.cwd, projectBase, path.sep) : 0;
|
|
@@ -335,7 +335,7 @@ async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemProm
|
|
|
335
335
|
// User-level: ~/.gemini/system.md
|
|
336
336
|
const userSystemMd = getUserPath(ctx, "gemini", "system.md");
|
|
337
337
|
if (userSystemMd) {
|
|
338
|
-
const content = await readFile(userSystemMd);
|
|
338
|
+
const content = await readFile(userSystemMd, ctx.signal);
|
|
339
339
|
if (content) {
|
|
340
340
|
items.push({
|
|
341
341
|
path: userSystemMd,
|
|
@@ -349,7 +349,7 @@ async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemProm
|
|
|
349
349
|
// Project-level: .gemini/system.md
|
|
350
350
|
const projectSystemMd = getProjectPath(ctx, "gemini", "system.md");
|
|
351
351
|
if (projectSystemMd) {
|
|
352
|
-
const content = await readFile(projectSystemMd);
|
|
352
|
+
const content = await readFile(projectSystemMd, ctx.signal);
|
|
353
353
|
if (content) {
|
|
354
354
|
items.push({
|
|
355
355
|
path: projectSystemMd,
|
package/src/discovery/github.ts
CHANGED
|
@@ -35,7 +35,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
|
|
35
35
|
|
|
36
36
|
const copilotInstructionsPath = getProjectPath(ctx, "github", "copilot-instructions.md");
|
|
37
37
|
if (copilotInstructionsPath) {
|
|
38
|
-
const content = await readFile(copilotInstructionsPath);
|
|
38
|
+
const content = await readFile(copilotInstructionsPath, ctx.signal);
|
|
39
39
|
if (content) {
|
|
40
40
|
const fileDir = path.dirname(copilotInstructionsPath);
|
|
41
41
|
const depth = calculateDepth(ctx.cwd, fileDir, path.sep);
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -282,6 +282,7 @@ export interface ScanSkillsFromDirOptions {
|
|
|
282
282
|
providerId: string;
|
|
283
283
|
level: "user" | "project";
|
|
284
284
|
requireDescription?: boolean;
|
|
285
|
+
signal?: AbortSignal;
|
|
285
286
|
}
|
|
286
287
|
|
|
287
288
|
// Stable ordering used for skill lists in prompts: name (case-insensitive), then name, then path.
|
|
@@ -304,8 +305,11 @@ export async function scanSkillsFromDir(
|
|
|
304
305
|
|
|
305
306
|
let entries: fs.Dirent[];
|
|
306
307
|
try {
|
|
308
|
+
options.signal?.throwIfAborted();
|
|
307
309
|
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
310
|
+
options.signal?.throwIfAborted();
|
|
308
311
|
} catch (error) {
|
|
312
|
+
if (options.signal?.aborted) throw error;
|
|
309
313
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
310
314
|
warnings.push(`Failed to read skills directory: ${dir} (${String(error)})`);
|
|
311
315
|
}
|
|
@@ -313,7 +317,7 @@ export async function scanSkillsFromDir(
|
|
|
313
317
|
}
|
|
314
318
|
const loadSkill = async (skillPath: string) => {
|
|
315
319
|
try {
|
|
316
|
-
const content = await readFile(skillPath);
|
|
320
|
+
const content = await readFile(skillPath, options.signal);
|
|
317
321
|
if (!content) return;
|
|
318
322
|
const { frontmatter, body } = parseFrontmatter(content, { source: skillPath });
|
|
319
323
|
if (frontmatter.enabled === false) {
|
|
@@ -333,13 +337,15 @@ export async function scanSkillsFromDir(
|
|
|
333
337
|
level,
|
|
334
338
|
_source: createSourceMeta(providerId, skillPath, level),
|
|
335
339
|
});
|
|
336
|
-
} catch {
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (options.signal?.aborted) throw error;
|
|
337
342
|
warnings.push(`Failed to read skill file: ${skillPath}`);
|
|
338
343
|
}
|
|
339
344
|
};
|
|
340
345
|
|
|
341
346
|
const work = [];
|
|
342
347
|
for (const entry of entries) {
|
|
348
|
+
options.signal?.throwIfAborted();
|
|
343
349
|
if (entry.name.startsWith(".")) continue;
|
|
344
350
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
345
351
|
const skillPath = path.join(dir, entry.name, "SKILL.md");
|
|
@@ -679,12 +685,13 @@ export function parseXcshPluginsRegistry(content: string): XcshPluginsRegistry |
|
|
|
679
685
|
* This is the single source of truth for "active project root" used by install,
|
|
680
686
|
* uninstall, list, upgrade, discovery, and doctor. Deterministic for a given `cwd`.
|
|
681
687
|
*/
|
|
682
|
-
export async function resolveActiveProjectRegistryPath(cwd: string): Promise<string | null> {
|
|
688
|
+
export async function resolveActiveProjectRegistryPath(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
683
689
|
// Pass 1: walk up looking for an existing .xcsh/ directory (nearest wins).
|
|
684
690
|
// Stop before os.homedir() — ~/.xcsh/ is the user-level config dir, not a project root.
|
|
685
691
|
const homeDir = os.homedir();
|
|
686
692
|
let dir = path.resolve(cwd);
|
|
687
693
|
while (dir !== homeDir) {
|
|
694
|
+
signal?.throwIfAborted();
|
|
688
695
|
try {
|
|
689
696
|
const stat = await fs.promises.stat(path.join(dir, CONFIG_DIR_NAME));
|
|
690
697
|
if (stat.isDirectory()) {
|
|
@@ -701,6 +708,7 @@ export async function resolveActiveProjectRegistryPath(cwd: string): Promise<str
|
|
|
701
708
|
// Pass 2: walk up looking for .git as a fallback anchor.
|
|
702
709
|
dir = path.resolve(cwd);
|
|
703
710
|
while (dir !== homeDir) {
|
|
711
|
+
signal?.throwIfAborted();
|
|
704
712
|
try {
|
|
705
713
|
await fs.promises.stat(path.join(dir, ".git"));
|
|
706
714
|
return path.join(dir, CONFIG_DIR_NAME, "plugins", "installed_plugins.json");
|
|
@@ -737,6 +745,9 @@ export async function resolveOrDefaultProjectRegistryPath(cwd: string): Promise<
|
|
|
737
745
|
}
|
|
738
746
|
|
|
739
747
|
const pluginRootsCache = new Map<string, { roots: XcshPluginRoot[]; warnings: string[] }>();
|
|
748
|
+
const pluginSummariesCache = new Map<string, XcshPluginSummary[]>();
|
|
749
|
+
const pluginSummariesInFlight = new Map<string, Promise<{ summaries: XcshPluginSummary[]; generation: number }>>();
|
|
750
|
+
let pluginCacheGeneration = 0;
|
|
740
751
|
|
|
741
752
|
/**
|
|
742
753
|
* List all installed marketplace plugin roots from the plugin cache.
|
|
@@ -748,8 +759,10 @@ const pluginRootsCache = new Map<string, { roots: XcshPluginRoot[]; warnings: st
|
|
|
748
759
|
export async function listXcshPluginRoots(
|
|
749
760
|
home: string,
|
|
750
761
|
cwd?: string,
|
|
762
|
+
signal?: AbortSignal,
|
|
751
763
|
): Promise<{ roots: XcshPluginRoot[]; warnings: string[] }> {
|
|
752
|
-
|
|
764
|
+
signal?.throwIfAborted();
|
|
765
|
+
const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd, signal) : null;
|
|
753
766
|
const cacheKey = `${home}:${resolvedProjectPath ?? ""}`;
|
|
754
767
|
const cached = pluginRootsCache.get(cacheKey);
|
|
755
768
|
if (cached) return cached;
|
|
@@ -758,7 +771,7 @@ export async function listXcshPluginRoots(
|
|
|
758
771
|
const warnings: string[] = [];
|
|
759
772
|
const projectRoots: XcshPluginRoot[] = [];
|
|
760
773
|
const marketplaceStates = new Map<string, MarketplaceRegistryEntry>();
|
|
761
|
-
const marketplacesContent = await readFile(path.join(home, getConfigDirName(), "marketplaces.json"));
|
|
774
|
+
const marketplacesContent = await readFile(path.join(home, getConfigDirName(), "marketplaces.json"), signal);
|
|
762
775
|
if (marketplacesContent) {
|
|
763
776
|
const marketplacesRegistry = tryParseJson<{ marketplaces?: MarketplaceRegistryEntry[] }>(marketplacesContent);
|
|
764
777
|
for (const marketplace of marketplacesRegistry?.marketplaces ?? []) {
|
|
@@ -769,7 +782,7 @@ export async function listXcshPluginRoots(
|
|
|
769
782
|
// ── Installed plugins registry ───────────────────────────────────────────
|
|
770
783
|
// Path derived from `home` (not os.homedir()) so test isolation works when home is overridden.
|
|
771
784
|
const registryPath = path.join(home, getConfigDirName(), "plugins", "installed_plugins.json");
|
|
772
|
-
const content = await readFile(registryPath);
|
|
785
|
+
const content = await readFile(registryPath, signal);
|
|
773
786
|
if (content) {
|
|
774
787
|
const registry = parseXcshPluginsRegistry(content);
|
|
775
788
|
if (registry) {
|
|
@@ -812,7 +825,7 @@ export async function listXcshPluginRoots(
|
|
|
812
825
|
// Project entries take precedence over user entries for the same plugin ID.
|
|
813
826
|
// Skip if the project registry is the same file as the user registry (home === cwd).
|
|
814
827
|
if (resolvedProjectPath && resolvedProjectPath !== registryPath) {
|
|
815
|
-
const projectContent = await readFile(resolvedProjectPath);
|
|
828
|
+
const projectContent = await readFile(resolvedProjectPath, signal);
|
|
816
829
|
if (projectContent) {
|
|
817
830
|
const projectRegistry = parseXcshPluginsRegistry(projectContent);
|
|
818
831
|
if (projectRegistry) {
|
|
@@ -890,6 +903,22 @@ export interface XcshPluginSummary {
|
|
|
890
903
|
description: string;
|
|
891
904
|
}
|
|
892
905
|
|
|
906
|
+
export interface XcshPluginSummaryLoadDependencies {
|
|
907
|
+
resolveProjectRegistryPath: (cwd: string, signal?: AbortSignal) => Promise<string | null>;
|
|
908
|
+
loadRoots: (
|
|
909
|
+
home: string,
|
|
910
|
+
cwd?: string,
|
|
911
|
+
signal?: AbortSignal,
|
|
912
|
+
) => Promise<{ roots: XcshPluginRoot[]; warnings: string[] }>;
|
|
913
|
+
loadSummary: (root: { plugin: string; path: string }, signal?: AbortSignal) => Promise<XcshPluginSummary>;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export interface XcshPluginSummaryLoadResult {
|
|
917
|
+
summaries: XcshPluginSummary[];
|
|
918
|
+
cacheStatus: "completed" | "cached";
|
|
919
|
+
generation: number;
|
|
920
|
+
}
|
|
921
|
+
|
|
893
922
|
/**
|
|
894
923
|
* Sanitize a manifest/package description for single-line rendering in the system prompt.
|
|
895
924
|
* Collapses whitespace runs to single spaces, trims, and caps length (appending "…" if truncated).
|
|
@@ -903,7 +932,10 @@ function sanitizePluginDescription(description: unknown): string {
|
|
|
903
932
|
}
|
|
904
933
|
|
|
905
934
|
/** Read a plugin's human name+description from its own manifest. Fail-safe: never throws. */
|
|
906
|
-
export async function readPluginSummary(
|
|
935
|
+
export async function readPluginSummary(
|
|
936
|
+
root: { plugin: string; path: string },
|
|
937
|
+
signal?: AbortSignal,
|
|
938
|
+
): Promise<XcshPluginSummary> {
|
|
907
939
|
// Precedence mirrors MarketplaceManager#resolvePluginVersion.
|
|
908
940
|
const candidates: Array<{ rel: string; pick: (m: any) => { name?: string; description?: string } }> = [
|
|
909
941
|
{ rel: ".xcsh-plugin/plugin.json", pick: m => ({ name: m?.name, description: m?.description }) },
|
|
@@ -913,32 +945,85 @@ export async function readPluginSummary(root: { plugin: string; path: string }):
|
|
|
913
945
|
},
|
|
914
946
|
];
|
|
915
947
|
for (const c of candidates) {
|
|
948
|
+
signal?.throwIfAborted();
|
|
916
949
|
try {
|
|
917
|
-
const
|
|
950
|
+
const manifestPath = path.join(root.path, c.rel);
|
|
951
|
+
const stat = await fs.promises.stat(manifestPath);
|
|
952
|
+
if (!stat.isFile()) continue;
|
|
953
|
+
const content = await fs.promises.readFile(manifestPath, { encoding: "utf8", signal });
|
|
954
|
+
const m = JSON.parse(content);
|
|
918
955
|
const { name, description } = c.pick(m);
|
|
919
956
|
if (name || description) {
|
|
920
957
|
return { id: root.plugin, name: name ?? root.plugin, description: sanitizePluginDescription(description) };
|
|
921
958
|
}
|
|
922
|
-
} catch {
|
|
959
|
+
} catch (error) {
|
|
960
|
+
if (signal?.aborted) throw error;
|
|
923
961
|
// try next candidate
|
|
924
962
|
}
|
|
925
963
|
}
|
|
926
964
|
return { id: root.plugin, name: root.plugin, description: "" };
|
|
927
965
|
}
|
|
928
966
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
967
|
+
const defaultPluginSummaryLoadDependencies: XcshPluginSummaryLoadDependencies = {
|
|
968
|
+
resolveProjectRegistryPath: resolveActiveProjectRegistryPath,
|
|
969
|
+
loadRoots: listXcshPluginRoots,
|
|
970
|
+
loadSummary: readPluginSummary,
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
/** Load the installed-plugin summary snapshot, deduplicating concurrent discovery by home/project scope. */
|
|
974
|
+
export async function loadXcshPluginSummaries(
|
|
975
|
+
home: string,
|
|
976
|
+
cwd?: string,
|
|
977
|
+
signal?: AbortSignal,
|
|
978
|
+
dependencies: XcshPluginSummaryLoadDependencies = defaultPluginSummaryLoadDependencies,
|
|
979
|
+
): Promise<XcshPluginSummaryLoadResult> {
|
|
980
|
+
signal?.throwIfAborted();
|
|
981
|
+
const resolvedProjectPath = cwd ? await dependencies.resolveProjectRegistryPath(cwd, signal) : null;
|
|
982
|
+
const cacheKey = `${home}:${resolvedProjectPath ?? ""}`;
|
|
983
|
+
const cached = pluginSummariesCache.get(cacheKey);
|
|
984
|
+
if (cached) return { summaries: cached, cacheStatus: "cached", generation: pluginCacheGeneration };
|
|
985
|
+
|
|
986
|
+
const existing = pluginSummariesInFlight.get(cacheKey);
|
|
987
|
+
if (existing) {
|
|
988
|
+
const result = await existing;
|
|
989
|
+
return { ...result, cacheStatus: "cached" };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const generation = pluginCacheGeneration;
|
|
993
|
+
const load = (async () => {
|
|
994
|
+
signal?.throwIfAborted();
|
|
995
|
+
const { roots } = await dependencies.loadRoots(home, cwd, signal);
|
|
933
996
|
const seen = new Set<string>();
|
|
934
997
|
const out: XcshPluginSummary[] = [];
|
|
935
998
|
for (const r of roots) {
|
|
999
|
+
signal?.throwIfAborted();
|
|
936
1000
|
if (seen.has(r.plugin)) continue;
|
|
937
1001
|
seen.add(r.plugin);
|
|
938
|
-
out.push(await
|
|
1002
|
+
out.push(await dependencies.loadSummary(r, signal));
|
|
939
1003
|
}
|
|
940
|
-
|
|
941
|
-
|
|
1004
|
+
const summaries = out.sort((a, b) => a.name.localeCompare(b.name));
|
|
1005
|
+
if (generation === pluginCacheGeneration) pluginSummariesCache.set(cacheKey, summaries);
|
|
1006
|
+
return { summaries, generation };
|
|
1007
|
+
})();
|
|
1008
|
+
pluginSummariesInFlight.set(cacheKey, load);
|
|
1009
|
+
try {
|
|
1010
|
+
return { ...(await load), cacheStatus: "completed" };
|
|
1011
|
+
} finally {
|
|
1012
|
+
if (pluginSummariesInFlight.get(cacheKey) === load) pluginSummariesInFlight.delete(cacheKey);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** All installed plugins as {name, description}, sorted by name, deduped by plugin id. */
|
|
1017
|
+
export async function listXcshPluginSummaries(
|
|
1018
|
+
home: string,
|
|
1019
|
+
cwd?: string,
|
|
1020
|
+
signal?: AbortSignal,
|
|
1021
|
+
dependencies?: XcshPluginSummaryLoadDependencies,
|
|
1022
|
+
): Promise<XcshPluginSummary[]> {
|
|
1023
|
+
try {
|
|
1024
|
+
return (await loadXcshPluginSummaries(home, cwd, signal, dependencies)).summaries;
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
if (signal?.aborted) throw error;
|
|
942
1027
|
return [];
|
|
943
1028
|
}
|
|
944
1029
|
}
|
|
@@ -947,7 +1032,10 @@ export async function listXcshPluginSummaries(home: string, cwd?: string): Promi
|
|
|
947
1032
|
* Clear the plugin roots cache (useful for testing or when plugins change).
|
|
948
1033
|
*/
|
|
949
1034
|
export function clearXcshPluginRootsCache(): void {
|
|
1035
|
+
pluginCacheGeneration += 1;
|
|
950
1036
|
pluginRootsCache.clear();
|
|
1037
|
+
pluginSummariesCache.clear();
|
|
1038
|
+
pluginSummariesInFlight.clear();
|
|
951
1039
|
preloadedPluginRoots = [...injectedPluginDirRoots];
|
|
952
1040
|
// Re-warm preloaded roots asynchronously so sync LSP config reads stay valid
|
|
953
1041
|
if (lastPreloadHome) {
|
|
@@ -955,6 +1043,11 @@ export function clearXcshPluginRootsCache(): void {
|
|
|
955
1043
|
}
|
|
956
1044
|
}
|
|
957
1045
|
|
|
1046
|
+
/** Monotonic generation used by session snapshots to detect explicit plugin invalidation. */
|
|
1047
|
+
export function getXcshPluginCacheGeneration(): number {
|
|
1048
|
+
return pluginCacheGeneration;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
958
1051
|
// ── Preloaded plugin roots (for sync consumers like LSP config) ─────────────
|
|
959
1052
|
// Populated at startup by preloadPluginRoots(). Read synchronously by
|
|
960
1053
|
// getPreloadedPluginRoots(). Safe degradation: empty array if not warmed.
|