@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "21.40.0",
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.40.0",
68
- "@f5-sales-demo/pi-ai": "21.40.0",
69
- "@f5-sales-demo/pi-natives": "21.40.0",
70
- "@f5-sales-demo/pi-resource-management": "21.40.0",
71
- "@f5-sales-demo/pi-tui": "21.40.0",
72
- "@f5-sales-demo/pi-utils": "21.40.0",
73
- "@f5-sales-demo/xcsh-stats": "21.40.0",
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",
@@ -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
- const content = await Bun.file(abs).text();
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
- const entries = await readDirEntries(current);
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
- const entries = await readDirEntries(current);
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
  }
@@ -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
- const repoRoot = await findRepoRoot(cwd);
239
- const ctx: LoadContext = { cwd, home, repoRoot };
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);
@@ -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);
@@ -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
  };
@@ -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
  }),
@@ -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,
@@ -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
 
@@ -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,
@@ -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);
@@ -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
- const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
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(root: { plugin: string; path: string }): Promise<XcshPluginSummary> {
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 m = await Bun.file(path.join(root.path, c.rel)).json();
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
- /** All installed plugins as {name, description}, sorted by name, deduped by plugin id. */
930
- export async function listXcshPluginSummaries(home: string, cwd?: string): Promise<XcshPluginSummary[]> {
931
- try {
932
- const { roots } = await listXcshPluginRoots(home, cwd);
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 readPluginSummary(r));
1002
+ out.push(await dependencies.loadSummary(r, signal));
939
1003
  }
940
- return out.sort((a, b) => a.name.localeCompare(b.name));
941
- } catch {
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.