@f5-sales-demo/xcsh 19.91.0 → 19.92.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 +3 -2
- package/src/extensibility/extensions/bundled/herdr-reporter.ts +112 -48
- package/src/extensibility/skills.ts +20 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/rpc/rpc-client.ts +9 -0
- package/src/modes/rpc/rpc-mode.ts +8 -0
- package/src/modes/rpc/rpc-types.ts +9 -0
- package/src/system-prompt.ts +56 -14
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.92.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": "1.3.0",
|
|
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.92.0",
|
|
60
|
+
"@f5-sales-demo/pi-agent-core": "19.92.0",
|
|
61
|
+
"@f5-sales-demo/pi-ai": "19.92.0",
|
|
62
|
+
"@f5-sales-demo/pi-natives": "19.92.0",
|
|
63
|
+
"@f5-sales-demo/pi-resource-management": "19.92.0",
|
|
64
|
+
"@f5-sales-demo/pi-tui": "19.92.0",
|
|
65
|
+
"@f5-sales-demo/pi-utils": "19.92.0",
|
|
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
|
-
|
|
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 {
|
|
@@ -29,6 +29,24 @@ import type { ExtensionAPI, ExtensionContext } from "@f5-sales-demo/xcsh";
|
|
|
29
29
|
* The extension is completely inert unless it is running inside a herdr pane
|
|
30
30
|
* (detected via `HERDR_PANE_ID`), so it has zero effect for users who do not run
|
|
31
31
|
* xcsh under herdr.
|
|
32
|
+
*
|
|
33
|
+
* Sequencing contract with herdr — do not regress. herdr keeps one monotonic
|
|
34
|
+
* `seq` per *source*, shared across every method, and silently discards any frame
|
|
35
|
+
* whose `seq` is not greater than the last it accepted for `herdr:xcsh`. Two
|
|
36
|
+
* consequences drive the design below, and both previously cost panes their
|
|
37
|
+
* resume reference:
|
|
38
|
+
*
|
|
39
|
+
* 1. `seq` is seeded from the wall clock, not 0, so a restarted xcsh always
|
|
40
|
+
* outranks its predecessor in the same pane. With a per-process counter from
|
|
41
|
+
* 0, every frame from the second xcsh in a pane looks stale and is dropped.
|
|
42
|
+
* 2. Frames go out one at a time through `enqueue`. They used to be
|
|
43
|
+
* fire-and-forget on independent sockets, so ordering was left to chance.
|
|
44
|
+
* 3. The state frame is always sent *before* the session frame. herdr discards a
|
|
45
|
+
* `pane.report_agent_session` for a pane whose agent it does not yet own, so
|
|
46
|
+
* the state frame has to establish `herdr:xcsh` as the pane's agent first.
|
|
47
|
+
* Verified against a live herdr: session-then-state (even with a correctly
|
|
48
|
+
* ascending `seq`) leaves `agent_session` null, while state-then-session
|
|
49
|
+
* records it.
|
|
32
50
|
*/
|
|
33
51
|
|
|
34
52
|
const HERDR_AGENT_LABEL = "xcsh";
|
|
@@ -42,26 +60,43 @@ const SOCKET_TIMEOUT_MS = 2000;
|
|
|
42
60
|
|
|
43
61
|
/**
|
|
44
62
|
* Write one newline-delimited JSON-RPC request to herdr's unix socket and close.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
63
|
+
* The response is ignored and every failure path is reported via `onError`
|
|
64
|
+
* without throwing, so a dead socket never disturbs the agent.
|
|
65
|
+
*
|
|
66
|
+
* Resolves once the frame has been flushed (or the attempt failed or timed out),
|
|
67
|
+
* which is what lets callers order frames relative to one another.
|
|
47
68
|
*/
|
|
48
69
|
function sendToHerdrSocket(
|
|
49
70
|
socketPath: string,
|
|
50
71
|
method: string,
|
|
51
72
|
params: Record<string, unknown>,
|
|
52
73
|
onError: (err: unknown) => void,
|
|
53
|
-
): void {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
return new Promise<void>(resolve => {
|
|
76
|
+
let settled = false;
|
|
77
|
+
const settle = (): void => {
|
|
78
|
+
if (settled) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
settled = true;
|
|
82
|
+
resolve();
|
|
83
|
+
};
|
|
84
|
+
const conn = connect({ path: socketPath });
|
|
85
|
+
// Never let a report keep xcsh's event loop alive.
|
|
86
|
+
conn.unref();
|
|
87
|
+
conn.once("error", err => {
|
|
88
|
+
onError(err);
|
|
89
|
+
conn.destroy();
|
|
90
|
+
settle();
|
|
91
|
+
});
|
|
92
|
+
conn.once("connect", () => {
|
|
93
|
+
conn.end(`${JSON.stringify({ id: "xcsh:herdr-reporter", method, params })}\n`, settle);
|
|
94
|
+
});
|
|
95
|
+
conn.setTimeout(SOCKET_TIMEOUT_MS, () => {
|
|
96
|
+
conn.destroy();
|
|
97
|
+
settle();
|
|
98
|
+
});
|
|
63
99
|
});
|
|
64
|
-
conn.setTimeout(SOCKET_TIMEOUT_MS, () => conn.destroy());
|
|
65
100
|
}
|
|
66
101
|
|
|
67
102
|
/** Translate a JSON-RPC report/release into `herdr` CLI arguments (fallback). */
|
|
@@ -90,10 +125,19 @@ export default function herdrReporter(pi: ExtensionAPI): void {
|
|
|
90
125
|
return;
|
|
91
126
|
}
|
|
92
127
|
|
|
93
|
-
|
|
128
|
+
// Seeded from the wall clock at microsecond scale rather than 0 so a new xcsh
|
|
129
|
+
// process in a pane always starts above whatever the previous process reached.
|
|
130
|
+
// Matches the pi/omp reporters and stays well inside Number.MAX_SAFE_INTEGER.
|
|
131
|
+
let seq = Date.now() * 1000;
|
|
94
132
|
// Tracks whether an interactive prompt is currently awaiting the user, so an
|
|
95
133
|
// agent_end that fires while a prompt is open is reported as blocked, not idle.
|
|
96
134
|
let promptOpen = false;
|
|
135
|
+
// Last session ref already delivered, so repeated lifecycle events do not
|
|
136
|
+
// re-send an unchanged ref every turn.
|
|
137
|
+
let lastSessionRefKey: string | undefined;
|
|
138
|
+
// Serializes frames: herdr compares `seq` across all methods for this source,
|
|
139
|
+
// so frames must reach it in the order they were generated.
|
|
140
|
+
let queue: Promise<void> = Promise.resolve();
|
|
97
141
|
|
|
98
142
|
pi.setLabel(HERDR_AGENT_LABEL);
|
|
99
143
|
|
|
@@ -103,17 +147,27 @@ export default function herdrReporter(pi: ExtensionAPI): void {
|
|
|
103
147
|
});
|
|
104
148
|
};
|
|
105
149
|
|
|
106
|
-
|
|
150
|
+
/** Chain a frame onto the tail of the send queue. Never rejects. */
|
|
151
|
+
const enqueue = (task: () => Promise<void>): Promise<void> => {
|
|
152
|
+
queue = queue.then(task, task).catch(onError);
|
|
153
|
+
return queue;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const send = (method: string, params: Record<string, unknown>): Promise<void> => {
|
|
107
157
|
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
108
158
|
if (socketPath) {
|
|
109
|
-
sendToHerdrSocket(socketPath, method, params, onError);
|
|
110
|
-
return;
|
|
159
|
+
return enqueue(() => sendToHerdrSocket(socketPath, method, params, onError));
|
|
111
160
|
}
|
|
112
161
|
// Fallback for the rare case herdr did not inject a socket path.
|
|
113
|
-
|
|
162
|
+
return enqueue(() =>
|
|
163
|
+
pi
|
|
164
|
+
.exec("herdr", toCliArgs(method, params))
|
|
165
|
+
.then(() => undefined)
|
|
166
|
+
.catch(onError),
|
|
167
|
+
);
|
|
114
168
|
};
|
|
115
169
|
|
|
116
|
-
const report = (state: "idle" | "working" | "blocked"): void =>
|
|
170
|
+
const report = (state: "idle" | "working" | "blocked"): Promise<void> =>
|
|
117
171
|
send(REPORT_METHOD, {
|
|
118
172
|
pane_id: paneId,
|
|
119
173
|
source: HERDR_SOURCE,
|
|
@@ -121,7 +175,6 @@ export default function herdrReporter(pi: ExtensionAPI): void {
|
|
|
121
175
|
state,
|
|
122
176
|
seq: seq++,
|
|
123
177
|
});
|
|
124
|
-
};
|
|
125
178
|
|
|
126
179
|
// Report the current session's identity so herdr can resume this pane
|
|
127
180
|
// (`xcsh --resume=<session>`) after a server restart. This is sent only over
|
|
@@ -129,10 +182,16 @@ export default function herdrReporter(pi: ExtensionAPI): void {
|
|
|
129
182
|
// reports via the CLI fallback). Prefer the absolute session file path, which
|
|
130
183
|
// herdr resumes directly; fall back to the session id for non-persisted
|
|
131
184
|
// sessions (e.g. print/RPC mode, where getSessionFile() is undefined).
|
|
132
|
-
|
|
185
|
+
//
|
|
186
|
+
// Called from every lifecycle handler because xcsh creates the session file
|
|
187
|
+
// lazily: getSessionFile() can still be undefined at session_start and even at
|
|
188
|
+
// agent_start, and a ref missed there would otherwise be lost until the next
|
|
189
|
+
// turn — long enough for a restart to lose the pane. Unchanged refs are
|
|
190
|
+
// suppressed, so the extra call sites cost nothing on the wire.
|
|
191
|
+
const reportSession = (ctx: ExtensionContext): Promise<void> => {
|
|
133
192
|
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
134
193
|
if (!socketPath) {
|
|
135
|
-
return;
|
|
194
|
+
return Promise.resolve();
|
|
136
195
|
}
|
|
137
196
|
let sessionRef: Record<string, unknown> | undefined;
|
|
138
197
|
try {
|
|
@@ -147,58 +206,63 @@ export default function herdrReporter(pi: ExtensionAPI): void {
|
|
|
147
206
|
}
|
|
148
207
|
} catch (err) {
|
|
149
208
|
onError(err);
|
|
150
|
-
return;
|
|
209
|
+
return Promise.resolve();
|
|
151
210
|
}
|
|
152
211
|
if (!sessionRef) {
|
|
153
|
-
return;
|
|
212
|
+
return Promise.resolve();
|
|
154
213
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
214
|
+
const refKey = JSON.stringify(sessionRef);
|
|
215
|
+
if (refKey === lastSessionRefKey) {
|
|
216
|
+
return Promise.resolve();
|
|
217
|
+
}
|
|
218
|
+
lastSessionRefKey = refKey;
|
|
219
|
+
const frame = {
|
|
220
|
+
pane_id: paneId,
|
|
221
|
+
source: HERDR_SOURCE,
|
|
222
|
+
agent: HERDR_AGENT_LABEL,
|
|
223
|
+
seq: seq++,
|
|
224
|
+
...sessionRef,
|
|
225
|
+
};
|
|
226
|
+
return enqueue(() => sendToHerdrSocket(socketPath, SESSION_METHOD, frame, onError));
|
|
167
227
|
};
|
|
168
228
|
|
|
169
229
|
// Announce presence and session identity as soon as the session is initialized.
|
|
170
|
-
pi.on("session_start", (_event, ctx) => {
|
|
171
|
-
|
|
172
|
-
|
|
230
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
231
|
+
await report("idle");
|
|
232
|
+
await reportSession(ctx);
|
|
173
233
|
});
|
|
174
234
|
|
|
175
235
|
// Busy while the agent loop is streaming a response. Re-report session identity
|
|
176
236
|
// in case the active session file changed (e.g. after /new, /resume, or /fork).
|
|
177
|
-
pi.on("agent_start", (_event, ctx) => {
|
|
178
|
-
|
|
179
|
-
|
|
237
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
238
|
+
await report("working");
|
|
239
|
+
await reportSession(ctx);
|
|
180
240
|
});
|
|
181
241
|
|
|
182
242
|
// Back to idle when the loop ends — unless we are waiting on a user prompt.
|
|
183
|
-
|
|
184
|
-
|
|
243
|
+
// This is also the first point where a lazily-created session file is certain
|
|
244
|
+
// to exist, so it is the backstop for capturing the resume ref.
|
|
245
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
246
|
+
await report(promptOpen ? "blocked" : "idle");
|
|
247
|
+
await reportSession(ctx);
|
|
185
248
|
});
|
|
186
249
|
|
|
187
250
|
// An interactive prompt (permission gate, ask tool, confirm/input) is
|
|
188
251
|
// awaiting the user: that is herdr's "needs attention" (blocked) state.
|
|
189
252
|
pi.on("user_prompt_start", () => {
|
|
190
253
|
promptOpen = true;
|
|
191
|
-
report("blocked");
|
|
254
|
+
void report("blocked");
|
|
192
255
|
});
|
|
193
256
|
|
|
194
|
-
pi.on("user_prompt_end", (_event, ctx) => {
|
|
257
|
+
pi.on("user_prompt_end", async (_event, ctx) => {
|
|
195
258
|
promptOpen = false;
|
|
196
|
-
report(ctx.isIdle() ? "idle" : "working");
|
|
259
|
+
await report(ctx.isIdle() ? "idle" : "working");
|
|
260
|
+
await reportSession(ctx);
|
|
197
261
|
});
|
|
198
262
|
|
|
199
263
|
// Relinquish pane authority so herdr stops showing xcsh once we exit.
|
|
200
264
|
pi.on("session_shutdown", () => {
|
|
201
|
-
send(RELEASE_METHOD, {
|
|
265
|
+
void send(RELEASE_METHOD, {
|
|
202
266
|
pane_id: paneId,
|
|
203
267
|
source: HERDR_SOURCE,
|
|
204
268
|
agent: HERDR_AGENT_LABEL,
|
|
@@ -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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.92.0",
|
|
21
|
+
"commit": "65c380d28a5d308cd6cc8382555be0498001e306",
|
|
22
|
+
"shortCommit": "65c380d",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.92.0",
|
|
25
|
+
"commitDate": "2026-07-25T23:53:03Z",
|
|
26
|
+
"buildDate": "2026-07-26T00:14:01.234Z",
|
|
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/65c380d28a5d308cd6cc8382555be0498001e306",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.92.0"
|
|
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";
|
package/src/system-prompt.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
129
|
-
|
|
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:
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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,
|
|
232
|
-
return { files: Array.from(discovered).sort().slice(0, limit), 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:
|
|
276
|
+
return { files: [], dirsVisited: ctx.dirsVisited };
|
|
235
277
|
}
|
|
236
278
|
}
|
|
237
279
|
|