@f5-sales-demo/xcsh 19.83.0 → 19.85.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": "19.83.0",
4
+ "version": "19.85.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",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.83.0",
60
- "@f5-sales-demo/pi-agent-core": "19.83.0",
61
- "@f5-sales-demo/pi-ai": "19.83.0",
62
- "@f5-sales-demo/pi-natives": "19.83.0",
63
- "@f5-sales-demo/pi-resource-management": "19.83.0",
64
- "@f5-sales-demo/pi-tui": "19.83.0",
65
- "@f5-sales-demo/pi-utils": "19.83.0",
59
+ "@f5-sales-demo/xcsh-stats": "19.85.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.85.0",
61
+ "@f5-sales-demo/pi-ai": "19.85.0",
62
+ "@f5-sales-demo/pi-natives": "19.85.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.85.0",
64
+ "@f5-sales-demo/pi-tui": "19.85.0",
65
+ "@f5-sales-demo/pi-utils": "19.85.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -555,7 +555,18 @@ function titleFromUrl(url: string): string {
555
555
  }
556
556
  }
557
557
 
558
- function extractReferences(msg: AssistantMessage): ChatReference[] {
558
+ /**
559
+ * Trailing characters a bare-URL match may greedily swallow at a markdown/prose
560
+ * boundary — markdown emphasis (`*_~`) and sentence/wrap punctuation. A real URL
561
+ * effectively never ends in these, so trimming them yields the intended link
562
+ * (e.g. `**https://…/llms.txt**` → `https://…/llms.txt`). The markdown-link branch
563
+ * is bounded by its closing `)` and needs no trimming.
564
+ */
565
+ function trimTrailingMarkup(url: string): string {
566
+ return url.replace(/[*_~,.;:!?'")\]]+$/, "");
567
+ }
568
+
569
+ export function extractReferences(msg: AssistantMessage): ChatReference[] {
559
570
  const refs: ChatReference[] = [];
560
571
  const seen = new Set<string>();
561
572
 
@@ -572,7 +583,7 @@ function extractReferences(msg: AssistantMessage): ChatReference[] {
572
583
 
573
584
  const bareUrlRegex = /(?<!\()(https?:\/\/[^\s)>\]]+)/g;
574
585
  for (let match = bareUrlRegex.exec(block.text); match !== null; match = bareUrlRegex.exec(block.text)) {
575
- const url = match[1];
586
+ const url = trimTrailingMarkup(match[1]);
576
587
  if (seen.has(url)) continue;
577
588
  seen.add(url);
578
589
  refs.push({ kind: classifyReferenceKind(url), title: titleFromUrl(url), url });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Supersede / recycle a foreground `xcsh office serve`.
3
+ *
4
+ * `office serve` binds a FIXED :8444 listener, so after a `brew upgrade` the old
5
+ * serve squats the port on the stale binary and a fresh `office serve` dies with
6
+ * "port 8444 in use". These helpers let the new serve step the old one down on
7
+ * start (supersede), and give `office recycle` (run from brew post_install) a way
8
+ * to stop the stale serve so the next start is clean — the office analog of
9
+ * `chrome recycle`.
10
+ *
11
+ * Safety: we only ever signal a PID that (a) actually holds :8444 AND (b) looks
12
+ * like an `office serve` (its `ps` command line contains "office serve"). A
13
+ * foreign holder is reported, never killed — so PID reuse can't make us signal an
14
+ * unrelated process.
15
+ */
16
+ import { OFFICE_PANE_PORT } from "./office-pane-server";
17
+
18
+ /** Injectable seams so the wait/signal logic is unit-testable without real procs. */
19
+ export interface ServeLifecycleDeps {
20
+ /** PID listening on `port`, or 0 if none/unknown (best-effort via lsof). */
21
+ pidListeningOn: (port: number) => number;
22
+ /** Whether `pid`'s command line is an `xcsh office serve`. */
23
+ isOfficeServe: (pid: number) => boolean;
24
+ /** Send a signal to `pid` (process.kill). */
25
+ signal: (pid: number, sig: NodeJS.Signals) => void;
26
+ /** Await `ms` (setTimeout in prod; immediate in tests). */
27
+ sleep: (ms: number) => Promise<void>;
28
+ }
29
+
30
+ export interface SupersedeResult {
31
+ /** True when a stale serve was found and stepped down. */
32
+ superseded: boolean;
33
+ /** The stale PID we signalled (present only when superseded). */
34
+ pid?: number;
35
+ }
36
+
37
+ const WAIT_MS = 3000;
38
+ const POLL_MS = 100;
39
+
40
+ /** PID listening on `port`, or 0 (best-effort; lsof unavailable → 0). */
41
+ function pidListeningOn(port: number): number {
42
+ try {
43
+ const out = Bun.spawnSync(["lsof", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"])
44
+ .stdout.toString()
45
+ .trim()
46
+ .split("\n")[0];
47
+ const pid = Number(out);
48
+ return Number.isInteger(pid) && pid > 0 ? pid : 0;
49
+ } catch {
50
+ return 0;
51
+ }
52
+ }
53
+
54
+ /** Whether `pid`'s full command line is an `xcsh office serve` (ps, best-effort). */
55
+ function isOfficeServe(pid: number): boolean {
56
+ try {
57
+ const out = Bun.spawnSync(["ps", "-p", String(pid), "-o", "command="]).stdout.toString();
58
+ return /office\s+serve/.test(out);
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ const defaultDeps: ServeLifecycleDeps = {
65
+ pidListeningOn,
66
+ isOfficeServe,
67
+ signal: (pid, sig) => process.kill(pid, sig),
68
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
69
+ };
70
+
71
+ /** SIGTERM `pid` and poll until `port` is released by it, or throw on timeout. */
72
+ async function signalAndWait(pid: number, port: number, deps: ServeLifecycleDeps): Promise<void> {
73
+ deps.signal(pid, "SIGTERM");
74
+ for (let waited = 0; waited < WAIT_MS; waited += POLL_MS) {
75
+ await deps.sleep(POLL_MS);
76
+ const now = deps.pidListeningOn(port);
77
+ if (now <= 0 || now !== pid) return; // freed (or a different serve took over)
78
+ }
79
+ throw new Error(`Timed out waiting for the stale office serve (PID ${pid}) to release port ${port}.`);
80
+ }
81
+
82
+ /**
83
+ * Step down a stale `office serve` holding `port` so a fresh serve can bind.
84
+ * No-op when the port is free. Throws (without signalling) when the holder is not
85
+ * an office serve — the caller should surface that rather than kill a stranger.
86
+ */
87
+ export async function supersedeStaleServe(
88
+ port = OFFICE_PANE_PORT,
89
+ deps: ServeLifecycleDeps = defaultDeps,
90
+ ): Promise<SupersedeResult> {
91
+ const pid = deps.pidListeningOn(port);
92
+ if (pid <= 0 || pid === process.pid) return { superseded: false };
93
+ if (!deps.isOfficeServe(pid)) {
94
+ throw new Error(
95
+ `Port ${port} is held by PID ${pid}, which isn't an xcsh office serve. Stop it manually, then retry.`,
96
+ );
97
+ }
98
+ await signalAndWait(pid, port, deps);
99
+ return { superseded: true, pid };
100
+ }
101
+
102
+ /**
103
+ * Stop a running `office serve` (if any) — used by `office recycle` and brew
104
+ * post_install so an upgrade clears the stale serve. Returns a human message;
105
+ * never throws for the ordinary cases (nothing running / foreign holder).
106
+ */
107
+ export async function recycleOfficeServe(
108
+ port = OFFICE_PANE_PORT,
109
+ deps: ServeLifecycleDeps = defaultDeps,
110
+ ): Promise<string> {
111
+ const pid = deps.pidListeningOn(port);
112
+ if (pid <= 0) return "No xcsh office serve is running.";
113
+ if (!deps.isOfficeServe(pid)) {
114
+ return `Port ${port} is held by PID ${pid}, which isn't an xcsh office serve — leaving it alone.`;
115
+ }
116
+ await signalAndWait(pid, port, deps);
117
+ return `Stopped the running office serve (PID ${pid}). Start it again with \`xcsh office serve\`.`;
118
+ }
@@ -13,13 +13,15 @@ import { LOCALIP_HOST } from "../browser/bridge-cert";
13
13
  import { type HeadlessChatBridge, startHeadlessChatBridge } from "../browser/headless-bridge";
14
14
  import {
15
15
  getOfficePaneDir,
16
+ OFFICE_PANE_PORT,
16
17
  type OfficePaneServer,
17
18
  readManifest,
18
19
  startOfficePaneServer,
19
20
  } from "../browser/office-pane-server";
21
+ import { recycleOfficeServe, supersedeStaleServe } from "../browser/office-serve-lifecycle";
20
22
 
21
23
  /** The subcommands `xcsh office` accepts (also the Args `options` constraint). */
22
- export const OFFICE_ACTIONS = ["serve", "manifest", "sideload"] as const;
24
+ export const OFFICE_ACTIONS = ["serve", "manifest", "sideload", "recycle"] as const;
23
25
  export type OfficeAction = (typeof OFFICE_ACTIONS)[number];
24
26
 
25
27
  /** Desktop Office apps a sideload can target. */
@@ -50,9 +52,10 @@ export async function writeManifest(outPath?: string): Promise<string> {
50
52
  export interface OfficeServeDeps {
51
53
  startOfficePaneServer: typeof startOfficePaneServer;
52
54
  startHeadlessChatBridge: typeof startHeadlessChatBridge;
55
+ supersedeStaleServe: typeof supersedeStaleServe;
53
56
  }
54
57
 
55
- const defaultServeDeps: OfficeServeDeps = { startOfficePaneServer, startHeadlessChatBridge };
58
+ const defaultServeDeps: OfficeServeDeps = { startOfficePaneServer, startHeadlessChatBridge, supersedeStaleServe };
56
59
 
57
60
  /** A running `office serve`: the pane file server, the (optional) chat bridge, and
58
61
  * a teardown that disposes both. */
@@ -70,6 +73,14 @@ export interface OfficeServeHandle {
70
73
  * Extracted from {@link runServe} so the start/teardown wiring is unit-testable.
71
74
  */
72
75
  export async function startOfficeServe(deps: OfficeServeDeps = defaultServeDeps): Promise<OfficeServeHandle> {
76
+ // Step down a stale serve squatting :8444 (e.g. left over from before a
77
+ // `brew upgrade`) so this start binds cleanly instead of "port 8444 in use".
78
+ const superseded = await deps.supersedeStaleServe(OFFICE_PANE_PORT);
79
+ if (superseded.superseded) {
80
+ console.log(
81
+ `Superseded a previous office serve (PID ${superseded.pid}) that was holding port ${OFFICE_PANE_PORT}.`,
82
+ );
83
+ }
73
84
  const server = await deps.startOfficePaneServer();
74
85
  let chat: HeadlessChatBridge | null = null;
75
86
  try {
@@ -170,5 +181,8 @@ export async function runOfficeCommand(args: OfficeCommandArgs): Promise<void> {
170
181
  case "sideload":
171
182
  await runSideload(args.app ?? "excel");
172
183
  return;
184
+ case "recycle":
185
+ console.log(await recycleOfficeServe());
186
+ return;
173
187
  }
174
188
  }
@@ -9,7 +9,7 @@ export default class Office extends Command {
9
9
 
10
10
  static args = {
11
11
  action: Args.string({
12
- description: "serve | manifest | sideload",
12
+ description: "serve | manifest | sideload | recycle",
13
13
  required: false,
14
14
  options: OFFICE_ACTIONS,
15
15
  }),
@@ -31,6 +31,7 @@ export default class Office extends Command {
31
31
  console.log(" serve Start the https://127-0-0-1.local-ip.sh:8444 task-pane listener");
32
32
  console.log(" manifest Print (or -o write) the add-in manifest.json");
33
33
  console.log(" sideload Sideload the add-in into a desktop Office app");
34
+ console.log(" recycle Stop a running serve (so the next `serve` starts clean after an upgrade)");
34
35
  return;
35
36
  }
36
37
  await runOfficeCommand({
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.83.0",
21
- "commit": "a0d8a930a0b6f8bba3730223529fbd078cc06ee9",
22
- "shortCommit": "a0d8a93",
20
+ "version": "19.85.0",
21
+ "commit": "6e7e1972db24c3151be8dceff0e37d424d674edd",
22
+ "shortCommit": "6e7e197",
23
23
  "branch": "main",
24
- "tag": "v19.83.0",
25
- "commitDate": "2026-07-23T13:56:27Z",
26
- "buildDate": "2026-07-23T14:21:15.547Z",
24
+ "tag": "v19.85.0",
25
+ "commitDate": "2026-07-23T16:36:07Z",
26
+ "buildDate": "2026-07-23T16:59:32.962Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/a0d8a930a0b6f8bba3730223529fbd078cc06ee9",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.83.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/6e7e1972db24c3151be8dceff0e37d424d674edd",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.85.0"
33
33
  };
package/src/sdk.ts CHANGED
@@ -112,6 +112,7 @@ import { SessionManager } from "./session/session-manager";
112
112
  import { closeAllConnections } from "./ssh/connection-manager";
113
113
  import { unmountAll } from "./ssh/sshfs-mount";
114
114
  import {
115
+ buildAgentsMdSearch,
115
116
  buildSystemPrompt as buildSystemPromptInternal,
116
117
  buildSystemPromptToolMetadata,
117
118
  loadProjectContextFiles as loadContextFilesInternal,
@@ -965,6 +966,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
965
966
  async () => options.contextFiles ?? (await discoverContextFiles(cwd, agentDir)),
966
967
  );
967
968
 
969
+ // Walk the CWD for nested XCSH.md ONCE per session — the walk is bounded but not
970
+ // free, so hoisting it here keeps every tool-refresh prompt rebuild off the tree
971
+ // (a large cwd like $HOME must never re-stall on set_host_tools). #2245.
972
+ const agentsMdSearch = await logger.time("buildAgentsMdSearch", buildAgentsMdSearch, cwd);
973
+
968
974
  let agent: Agent;
969
975
  let session!: AgentSession;
970
976
  let hasSession = false;
@@ -1596,6 +1602,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1596
1602
  cwd,
1597
1603
  skills,
1598
1604
  contextFiles,
1605
+ agentsMdSearch,
1599
1606
  tools: promptTools,
1600
1607
  toolNames,
1601
1608
  rules: rulebookRules,
@@ -1626,6 +1633,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1626
1633
  cwd,
1627
1634
  skills,
1628
1635
  contextFiles,
1636
+ agentsMdSearch,
1629
1637
  tools: promptTools,
1630
1638
  toolNames,
1631
1639
  rules: rulebookRules,
@@ -108,15 +108,30 @@ const AGENTS_MD_MIN_DEPTH = 1;
108
108
  const AGENTS_MD_MAX_DEPTH = 4;
109
109
  const AGENTS_MD_LIMIT = 200;
110
110
  const SYSTEM_PROMPT_PREP_TIMEOUT_MS = 5000;
111
+ // The walk's `limit` caps DISCOVERED files, not directories VISITED — so a dir
112
+ // with ~no XCSH.md (e.g. serving from $HOME) would otherwise traverse the entire
113
+ // tree to AGENTS_MD_MAX_DEPTH and stall prep. These bound the traversal itself:
114
+ // a directory-visit budget AND a wall-clock deadline, both well inside the 5s
115
+ // prep timeout, so discovery stays fast regardless of how few matches exist.
116
+ const AGENTS_MD_MAX_DIRS = 4000;
117
+ const AGENTS_MD_WALK_BUDGET_MS = 1500;
111
118
  const AGENTS_MD_EXCLUDED_DIRS = new Set(["node_modules", ".git"]);
112
119
 
113
- interface AgentsMdSearch {
120
+ /** A cited context-file search result (the nested XCSH.md files under the cwd). */
121
+ export interface AgentsMdSearch {
114
122
  scopePath: string;
115
123
  limit: number;
116
124
  pattern: string;
117
125
  files: string[];
118
126
  }
119
127
 
128
+ /** Mutable traversal budget shared across the recursive walk. */
129
+ interface WalkBudget {
130
+ readonly maxDirs: number;
131
+ readonly deadline: number;
132
+ dirsVisited: number;
133
+ }
134
+
120
135
  function normalizePath(value: string): string {
121
136
  return value.replace(/\\/g, "/");
122
137
  }
@@ -131,11 +146,24 @@ async function collectAgentsMdFiles(
131
146
  dir: string,
132
147
  depth: number,
133
148
  limit: number,
149
+ maxDepth: number,
134
150
  discovered: Set<string>,
151
+ budget: WalkBudget,
135
152
  ): Promise<void> {
136
- if (depth > AGENTS_MD_MAX_DEPTH || discovered.size >= limit) {
153
+ // Stop on any bound: depth, enough matches, the directory-visit budget, or the
154
+ // wall-clock deadline. The last two keep a match-poor tree (e.g. $HOME) bounded.
155
+ if (
156
+ depth > maxDepth ||
157
+ discovered.size >= limit ||
158
+ budget.dirsVisited >= budget.maxDirs ||
159
+ Date.now() >= budget.deadline
160
+ ) {
137
161
  return;
138
162
  }
163
+ // Reserve this directory's slot SYNCHRONOUSLY (before the readdir await) so a
164
+ // concurrent Promise.all fan-out can't blow past the budget: every scheduled
165
+ // sibling sees the updated count before it yields, making the cap effectively hard.
166
+ budget.dirsVisited++;
139
167
 
140
168
  let entries: fs.Dirent[];
141
169
  try {
@@ -157,7 +185,7 @@ async function collectAgentsMdFiles(
157
185
  }
158
186
  }
159
187
 
160
- if (depth === AGENTS_MD_MAX_DEPTH) {
188
+ if (depth === maxDepth) {
161
189
  return;
162
190
  }
163
191
 
@@ -168,24 +196,47 @@ async function collectAgentsMdFiles(
168
196
 
169
197
  await Promise.all(
170
198
  childDirs.map(async child => {
171
- if (discovered.size >= limit) return;
172
- await collectAgentsMdFiles(root, path.join(dir, child), depth + 1, limit, discovered);
199
+ if (discovered.size >= limit || budget.dirsVisited >= budget.maxDirs || Date.now() >= budget.deadline) return;
200
+ await collectAgentsMdFiles(root, path.join(dir, child), depth + 1, limit, maxDepth, discovered, budget);
173
201
  }),
174
202
  );
175
203
  }
176
204
 
177
- async function listAgentsMdFiles(root: string, limit: number): Promise<string[]> {
205
+ /** Options for {@link discoverAgentsMdFiles} (all bounded by defaults). */
206
+ export interface DiscoverAgentsMdOptions {
207
+ limit?: number;
208
+ maxDepth?: number;
209
+ maxDirs?: number;
210
+ budgetMs?: number;
211
+ }
212
+
213
+ /**
214
+ * Walk `root` (bounded by depth, match-limit, directory budget, and a wall-clock
215
+ * deadline) collecting nested `XCSH.md` paths. Returns the sorted matches plus the
216
+ * number of directories visited (for observability/tests). Never throws.
217
+ */
218
+ export async function discoverAgentsMdFiles(
219
+ root: string,
220
+ opts: DiscoverAgentsMdOptions = {},
221
+ ): Promise<{ files: string[]; dirsVisited: number }> {
222
+ const limit = opts.limit ?? AGENTS_MD_LIMIT;
223
+ const maxDepth = opts.maxDepth ?? AGENTS_MD_MAX_DEPTH;
224
+ const budget: WalkBudget = {
225
+ maxDirs: opts.maxDirs ?? AGENTS_MD_MAX_DIRS,
226
+ deadline: Date.now() + (opts.budgetMs ?? AGENTS_MD_WALK_BUDGET_MS),
227
+ dirsVisited: 0,
228
+ };
178
229
  try {
179
230
  const discovered = new Set<string>();
180
- await collectAgentsMdFiles(root, root, 0, limit, discovered);
181
- return Array.from(discovered).sort().slice(0, limit);
231
+ await collectAgentsMdFiles(root, root, 0, limit, maxDepth, discovered, budget);
232
+ return { files: Array.from(discovered).sort().slice(0, limit), dirsVisited: budget.dirsVisited };
182
233
  } catch {
183
- return [];
234
+ return { files: [], dirsVisited: budget.dirsVisited };
184
235
  }
185
236
  }
186
237
 
187
- async function buildAgentsMdSearch(cwd: string): Promise<AgentsMdSearch> {
188
- const files = await listAgentsMdFiles(cwd, AGENTS_MD_LIMIT);
238
+ export async function buildAgentsMdSearch(cwd: string): Promise<AgentsMdSearch> {
239
+ const { files } = await discoverAgentsMdFiles(cwd);
189
240
  return {
190
241
  scopePath: ".",
191
242
  limit: AGENTS_MD_LIMIT,
@@ -449,6 +500,9 @@ export interface BuildSystemPromptOptions {
449
500
  cwd?: string;
450
501
  /** Pre-loaded context files (skips discovery if provided). */
451
502
  contextFiles?: Array<{ path: string; content: string; depth?: number }>;
503
+ /** Pre-computed nested-XCSH.md search (skips the CWD walk if provided). Hoisted
504
+ * once per session so tool-refresh rebuilds never re-walk the tree. */
505
+ agentsMdSearch?: AgentsMdSearch;
452
506
  /**
453
507
  * Explicit disabled extension IDs applied to context-file discovery instead of the
454
508
  * global settings default. Pass `[]` to discover independent of process-wide settings.
@@ -519,6 +573,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
519
573
  toolNames: providedToolNames,
520
574
  cwd,
521
575
  contextFiles: providedContextFiles,
576
+ agentsMdSearch: providedAgentsMdSearch,
522
577
  skills: providedSkills,
523
578
  rules,
524
579
  alwaysApplyRules,
@@ -541,7 +596,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
541
596
  cwd: resolvedCwd,
542
597
  disabledExtensions: options.disabledExtensions,
543
598
  });
544
- const agentsMdSearchPromise = logger.time("buildAgentsMdSearch", buildAgentsMdSearch, resolvedCwd);
599
+ const agentsMdSearchPromise = providedAgentsMdSearch
600
+ ? Promise.resolve(providedAgentsMdSearch)
601
+ : logger.time("buildAgentsMdSearch", buildAgentsMdSearch, resolvedCwd);
545
602
  const mergedSkillsSettings = {
546
603
  ...skillsSettings,
547
604
  customDirectories: [...(skillsSettings?.customDirectories ?? []), ...(options.contextSkillDirs ?? [])],