@f5-sales-demo/xcsh 19.91.1 → 19.92.1

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.91.1",
4
+ "version": "19.92.1",
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": "1.3.0",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.91.1",
60
- "@f5-sales-demo/pi-agent-core": "19.91.1",
61
- "@f5-sales-demo/pi-ai": "19.91.1",
62
- "@f5-sales-demo/pi-natives": "19.91.1",
63
- "@f5-sales-demo/pi-resource-management": "19.91.1",
64
- "@f5-sales-demo/pi-tui": "19.91.1",
65
- "@f5-sales-demo/pi-utils": "19.91.1",
59
+ "@f5-sales-demo/xcsh-stats": "19.92.1",
60
+ "@f5-sales-demo/pi-agent-core": "19.92.1",
61
+ "@f5-sales-demo/pi-ai": "19.92.1",
62
+ "@f5-sales-demo/pi-natives": "19.92.1",
63
+ "@f5-sales-demo/pi-resource-management": "19.92.1",
64
+ "@f5-sales-demo/pi-tui": "19.92.1",
65
+ "@f5-sales-demo/pi-utils": "19.92.1",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import type { AssistantMessage, ImageContent } from "@f5-sales-demo/pi-ai";
4
4
  import { settings } from "../config/settings";
5
5
  import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
6
+ import { toSkillSummaries } from "../extensibility/skills";
6
7
  import {
7
8
  isRpcHostToolResult,
8
9
  isRpcHostToolUpdate,
@@ -428,8 +429,8 @@ export class ChatHandler {
428
429
  * already loaded on the session; the model actions them via the read tool + the
429
430
  * system prompt (Phase 2A enabled `read`), so this is enumeration only. */
430
431
  #handleListSkills(): void {
431
- const skills = this.#session.skills.map(s => ({ name: s.name, description: s.description }));
432
- this.#server.send({ type: "skills", skills } satisfies SkillsList);
432
+ // Shared projection: one place decides what crosses to a client (never paths).
433
+ this.#server.send({ type: "skills", skills: toSkillSummaries(this.#session.skills) } satisfies SkillsList);
433
434
  }
434
435
 
435
436
  #sendTerminal(chat: ActiveChat, frame: ChatDone | ChatError): void {
@@ -19,6 +19,26 @@ export interface Skill {
19
19
  _source?: SourceMeta;
20
20
  }
21
21
 
22
+ /** A skill as a CLIENT sees it: enough to render a menu entry, nothing more. */
23
+ export interface SkillSummary {
24
+ name: string;
25
+ description: string;
26
+ }
27
+
28
+ /**
29
+ * Project loaded skills onto the client-facing summary.
30
+ *
31
+ * `Skill` also carries `filePath`, `baseDir`, `source` and `_source` — the
32
+ * operator's on-disk layout. A UI surface (Office pane, Chrome side panel, VS Code
33
+ * webview) needs only the name and description to populate a menu, so this is the
34
+ * one place that decides what crosses the boundary. Shared by every transport
35
+ * (the `list_skills` bridge frame and the `list_skills` RPC command) so they can't
36
+ * drift into leaking different amounts.
37
+ */
38
+ export function toSkillSummaries(skills: readonly Skill[]): SkillSummary[] {
39
+ return skills.map(s => ({ name: s.name, description: s.description }));
40
+ }
41
+
22
42
  export interface SkillWarning {
23
43
  skillPath: string;
24
44
  message: string;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.91.1",
21
- "commit": "cb975aa015ff199ecdc38c20544471ebf32e3737",
22
- "shortCommit": "cb975aa",
20
+ "version": "19.92.1",
21
+ "commit": "f3e12dd43623f1171d4dff7c837532e64ab8bb4b",
22
+ "shortCommit": "f3e12dd",
23
23
  "branch": "main",
24
- "tag": "v19.91.1",
25
- "commitDate": "2026-07-25T17:10:56Z",
26
- "buildDate": "2026-07-25T17:31:09.895Z",
24
+ "tag": "v19.92.1",
25
+ "commitDate": "2026-07-26T01:09:53Z",
26
+ "buildDate": "2026-07-26T01:30:47.762Z",
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/cb975aa015ff199ecdc38c20544471ebf32e3737",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.91.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/f3e12dd43623f1171d4dff7c837532e64ab8bb4b",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.92.1"
33
33
  };
@@ -389,6 +389,15 @@ export class RpcClient {
389
389
  return this.#getData(response);
390
390
  }
391
391
 
392
+ /**
393
+ * List the session's loaded skills (name + description) so a host can populate a
394
+ * skills menu. Enumeration only — invoking one is just a prompt beginning `/name`.
395
+ */
396
+ async listSkills(): Promise<{ skills: Array<{ name: string; description: string }> }> {
397
+ const response = await this.#send({ type: "list_skills" });
398
+ return this.#getData(response);
399
+ }
400
+
392
401
  /**
393
402
  * Set thinking level.
394
403
  */
@@ -16,6 +16,7 @@ import type {
16
16
  ExtensionUIDialogOptions,
17
17
  ExtensionWidgetOptions,
18
18
  } from "../../extensibility/extensions";
19
+ import { toSkillSummaries } from "../../extensibility/skills";
19
20
  import {
20
21
  isRpcHostToolResult,
21
22
  isRpcHostToolUpdate,
@@ -636,6 +637,13 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
636
637
  });
637
638
  }
638
639
 
640
+ case "list_skills": {
641
+ // Enumeration only: skills already work through the read tool + system
642
+ // prompt. Mirrors the `list_skills` bridge frame the Office pane uses, via
643
+ // the same shared projection so neither transport can leak on-disk paths.
644
+ return success(id, "list_skills", { skills: toSkillSummaries(session.skills) });
645
+ }
646
+
639
647
  // =================================================================
640
648
  // Model
641
649
  // =================================================================
@@ -7,6 +7,7 @@
7
7
  import type { AgentMessage, ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
8
8
  import type { Effort, ImageContent, Model } from "@f5-sales-demo/pi-ai";
9
9
  import type { BashResult } from "../../exec/bash-executor";
10
+ import type { SkillSummary } from "../../extensibility/skills";
10
11
  // The host-tool wire types are transport-neutral and live in the shared host-tool
11
12
  // core so both the stdio RPC driver and the WS chat bridge use one vocabulary.
12
13
  import type {
@@ -48,6 +49,7 @@ export type RpcCommand =
48
49
  | { id?: string; type: "set_todos"; phases: TodoPhase[] }
49
50
  | { id?: string; type: "set_host_tools"; tools: RpcHostToolDefinition[] }
50
51
  | { id?: string; type: "get_integrations" }
52
+ | { id?: string; type: "list_skills" }
51
53
 
52
54
  // Model
53
55
  | { id?: string; type: "set_model"; provider: string; modelId: string }
@@ -132,6 +134,13 @@ export type RpcResponse =
132
134
  | { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
133
135
  | { id?: string; type: "response"; command: "set_todos"; success: true; data: { todoPhases: TodoPhase[] } }
134
136
  | { id?: string; type: "response"; command: "set_host_tools"; success: true; data: { toolNames: string[] } }
137
+ | {
138
+ id?: string;
139
+ type: "response";
140
+ command: "list_skills";
141
+ success: true;
142
+ data: { skills: SkillSummary[] };
143
+ }
135
144
  | {
136
145
  id?: string;
137
146
  type: "response";
@@ -6,7 +6,16 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import type { AgentTool } from "@f5-sales-demo/pi-agent-core";
9
- import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@f5-sales-demo/pi-utils";
9
+ import {
10
+ $env,
11
+ getGpuCachePath,
12
+ getProjectDir,
13
+ hasFsCode,
14
+ isEnoent,
15
+ logger,
16
+ prompt,
17
+ withTimeout,
18
+ } from "@f5-sales-demo/pi-utils";
10
19
  import { $ } from "bun";
11
20
  import { contextFileCapability } from "./capability/context-file";
12
21
  import { systemPromptCapability } from "./capability/system-prompt";
@@ -125,13 +134,42 @@ export interface AgentsMdSearch {
125
134
  files: string[];
126
135
  }
127
136
 
128
- /** Mutable traversal budget shared across the recursive walk. */
129
- interface WalkBudget {
137
+ /** Reads one directory. Injectable so tests can simulate a readdir that never settles. */
138
+ type ReaddirFn = (dir: string) => Promise<fs.Dirent[]>;
139
+
140
+ /** Mutable traversal budget + the reader, shared across the recursive walk. */
141
+ interface WalkContext {
130
142
  readonly maxDirs: number;
131
143
  readonly deadline: number;
144
+ readonly readdir: ReaddirFn;
132
145
  dirsVisited: number;
133
146
  }
134
147
 
148
+ /**
149
+ * Read one directory, giving up when the walk's deadline passes.
150
+ *
151
+ * The budget checks between directories are NOT enough on their own: a `readdir`
152
+ * that never settles — a TCC-protected or cloud-synced directory such as
153
+ * ~/Documents on a managed Mac — would park the walk forever at zero CPU, hanging
154
+ * `createAgentSession` and with it the Office pane's `set_host_tools` (#2399). The
155
+ * deadline has to be enforced HERE, around the syscall itself.
156
+ *
157
+ * Returns null when the directory could not be read in time (or at all); the
158
+ * caller treats that as "nothing here" and moves on.
159
+ *
160
+ * The abandoned read keeps a thread-pool slot until the OS releases it. That is
161
+ * bounded by the number of pathological directories and is strictly better than
162
+ * never returning.
163
+ */
164
+ async function readdirWithinBudget(dir: string, ctx: WalkContext): Promise<fs.Dirent[] | null> {
165
+ const remaining = ctx.deadline - Date.now();
166
+ if (remaining <= 0) return null;
167
+ // `withTimeout` rejects on expiry and clears its own timer; here expiry is an
168
+ // ordinary outcome, so both it and a real read failure collapse to null — the
169
+ // caller treats either as "nothing here".
170
+ return await withTimeout(ctx.readdir(dir), remaining, `readdir exceeded the walk budget: ${dir}`).catch(() => null);
171
+ }
172
+
135
173
  function normalizePath(value: string): string {
136
174
  return value.replace(/\\/g, "/");
137
175
  }
@@ -148,7 +186,7 @@ async function collectAgentsMdFiles(
148
186
  limit: number,
149
187
  maxDepth: number,
150
188
  discovered: Set<string>,
151
- budget: WalkBudget,
189
+ budget: WalkContext,
152
190
  ): Promise<void> {
153
191
  // Stop on any bound: depth, enough matches, the directory-visit budget, or the
154
192
  // wall-clock deadline. The last two keep a match-poor tree (e.g. $HOME) bounded.
@@ -165,12 +203,9 @@ async function collectAgentsMdFiles(
165
203
  // sibling sees the updated count before it yields, making the cap effectively hard.
166
204
  budget.dirsVisited++;
167
205
 
168
- let entries: fs.Dirent[];
169
- try {
170
- entries = await fs.promises.readdir(dir, { withFileTypes: true });
171
- } catch {
172
- return;
173
- }
206
+ // Deadline-guarded: a directory that never answers must not park the whole walk.
207
+ const entries = await readdirWithinBudget(dir, budget);
208
+ if (!entries) return;
174
209
 
175
210
  if (depth >= AGENTS_MD_MIN_DEPTH) {
176
211
  const hasAgentsMd = entries.some(entry => entry.isFile() && entry.name === "XCSH.md");
@@ -208,12 +243,18 @@ export interface DiscoverAgentsMdOptions {
208
243
  maxDepth?: number;
209
244
  maxDirs?: number;
210
245
  budgetMs?: number;
246
+ /** Directory reader; defaults to `fs.promises.readdir`. A test seam for
247
+ * simulating a filesystem that never answers. */
248
+ readdir?: ReaddirFn;
211
249
  }
212
250
 
213
251
  /**
214
252
  * Walk `root` (bounded by depth, match-limit, directory budget, and a wall-clock
215
253
  * deadline) collecting nested `XCSH.md` paths. Returns the sorted matches plus the
216
254
  * number of directories visited (for observability/tests). Never throws.
255
+ *
256
+ * The deadline is PREEMPTIVE: it bounds each `readdir` as well as the walk as a
257
+ * whole, so a directory that never answers cannot stall startup (#2399).
217
258
  */
218
259
  export async function discoverAgentsMdFiles(
219
260
  root: string,
@@ -221,17 +262,18 @@ export async function discoverAgentsMdFiles(
221
262
  ): Promise<{ files: string[]; dirsVisited: number }> {
222
263
  const limit = opts.limit ?? AGENTS_MD_LIMIT;
223
264
  const maxDepth = opts.maxDepth ?? AGENTS_MD_MAX_DEPTH;
224
- const budget: WalkBudget = {
265
+ const ctx: WalkContext = {
225
266
  maxDirs: opts.maxDirs ?? AGENTS_MD_MAX_DIRS,
226
267
  deadline: Date.now() + (opts.budgetMs ?? AGENTS_MD_WALK_BUDGET_MS),
268
+ readdir: opts.readdir ?? ((dir: string) => fs.promises.readdir(dir, { withFileTypes: true })),
227
269
  dirsVisited: 0,
228
270
  };
229
271
  try {
230
272
  const discovered = new Set<string>();
231
- await collectAgentsMdFiles(root, root, 0, limit, maxDepth, discovered, budget);
232
- return { files: Array.from(discovered).sort().slice(0, limit), dirsVisited: budget.dirsVisited };
273
+ await collectAgentsMdFiles(root, root, 0, limit, maxDepth, discovered, ctx);
274
+ return { files: Array.from(discovered).sort().slice(0, limit), dirsVisited: ctx.dirsVisited };
233
275
  } catch {
234
- return { files: [], dirsVisited: budget.dirsVisited };
276
+ return { files: [], dirsVisited: ctx.dirsVisited };
235
277
  }
236
278
  }
237
279