@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 +8 -8
- package/src/browser/chat-handler.ts +13 -2
- package/src/browser/office-serve-lifecycle.ts +118 -0
- package/src/cli/office-cli.ts +16 -2
- package/src/commands/office.ts +2 -1
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/sdk.ts +8 -0
- package/src/system-prompt.ts +69 -12
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.
|
|
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.
|
|
60
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
61
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
62
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
63
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
64
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
65
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cli/office-cli.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/office.ts
CHANGED
|
@@ -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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.85.0",
|
|
21
|
+
"commit": "6e7e1972db24c3151be8dceff0e37d424d674edd",
|
|
22
|
+
"shortCommit": "6e7e197",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.
|
|
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,
|
package/src/system-prompt.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 ?? [])],
|