@f5-sales-demo/xcsh 19.54.0 → 19.55.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/scripts/generate-extension-capabilities.ts +1 -0
- package/src/browser/capabilities.generated.ts +15 -3
- package/src/browser/capabilities.json +12 -1
- package/src/browser/chat-conformance.json +1 -1
- package/src/browser/extension-bridge.ts +83 -49
- package/src/browser/extension-page-actions.ts +0 -1
- package/src/cli/chrome-cli.ts +11 -6
- package/src/extensibility/extensions/bundled/herdr-reporter.ts +102 -0
- package/src/extensibility/extensions/loader.ts +36 -1
- package/src/extensibility/extensions/types.ts +24 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/main.ts +5 -6
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/sdk.ts +35 -0
- package/src/services/session-context-binding.ts +49 -0
- package/src/services/xcsh-context.ts +17 -22
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.55.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",
|
|
@@ -55,13 +55,13 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
57
57
|
"@mozilla/readability": "^0.6",
|
|
58
|
-
"@f5-sales-demo/xcsh-stats": "19.
|
|
59
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
60
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
61
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
62
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
63
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
64
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
58
|
+
"@f5-sales-demo/xcsh-stats": "19.55.0",
|
|
59
|
+
"@f5-sales-demo/pi-agent-core": "19.55.0",
|
|
60
|
+
"@f5-sales-demo/pi-ai": "19.55.0",
|
|
61
|
+
"@f5-sales-demo/pi-natives": "19.55.0",
|
|
62
|
+
"@f5-sales-demo/pi-resource-management": "19.55.0",
|
|
63
|
+
"@f5-sales-demo/pi-tui": "19.55.0",
|
|
64
|
+
"@f5-sales-demo/pi-utils": "19.55.0",
|
|
65
65
|
"@sinclair/typebox": "^0.34",
|
|
66
66
|
"@xterm/headless": "^6.0",
|
|
67
67
|
"ajv": "^8.20",
|
|
@@ -54,6 +54,7 @@ const output = [
|
|
|
54
54
|
"export interface ExtensionCapabilities {",
|
|
55
55
|
"\treadonly version: string;",
|
|
56
56
|
"\treadonly contractVersion: string;",
|
|
57
|
+
"\treadonly multiPortDiscovery?: boolean;",
|
|
57
58
|
"\treadonly protocol: string;",
|
|
58
59
|
"\treadonly tools: readonly ExtensionToolDef[];",
|
|
59
60
|
"\treadonly features: Record<string, unknown>;",
|
|
@@ -18,6 +18,7 @@ export interface ExtensionToolDef {
|
|
|
18
18
|
export interface ExtensionCapabilities {
|
|
19
19
|
readonly version: string;
|
|
20
20
|
readonly contractVersion: string;
|
|
21
|
+
readonly multiPortDiscovery?: boolean;
|
|
21
22
|
readonly protocol: string;
|
|
22
23
|
readonly tools: readonly ExtensionToolDef[];
|
|
23
24
|
readonly features: Record<string, unknown>;
|
|
@@ -25,7 +26,8 @@ export interface ExtensionCapabilities {
|
|
|
25
26
|
|
|
26
27
|
export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
27
28
|
"version": "0.1.0",
|
|
28
|
-
"contractVersion": "1.
|
|
29
|
+
"contractVersion": "1.5.0",
|
|
30
|
+
"multiPortDiscovery": true,
|
|
29
31
|
"protocol": "tool_request/result",
|
|
30
32
|
"tools": [
|
|
31
33
|
{
|
|
@@ -613,6 +615,16 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
|
613
615
|
},
|
|
614
616
|
"flags": {}
|
|
615
617
|
},
|
|
618
|
+
{
|
|
619
|
+
"name": "diag_bridges",
|
|
620
|
+
"summary": "List discovered xcsh bridges (port, tenant, env, sessionId, lastSeen) for multi-session diagnostics.",
|
|
621
|
+
"category": "read",
|
|
622
|
+
"params": {
|
|
623
|
+
"type": "object",
|
|
624
|
+
"properties": {}
|
|
625
|
+
},
|
|
626
|
+
"flags": {}
|
|
627
|
+
},
|
|
616
628
|
{
|
|
617
629
|
"name": "capture_login_flow",
|
|
618
630
|
"summary": "Diagnostic: captured login redirect chain annotated with tenant/env (Phase 0b).",
|
|
@@ -813,6 +825,6 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
|
813
825
|
}
|
|
814
826
|
};
|
|
815
827
|
|
|
816
|
-
export const EXTENSION_CONTRACT_VERSION = "1.
|
|
828
|
+
export const EXTENSION_CONTRACT_VERSION = "1.5.0";
|
|
817
829
|
|
|
818
|
-
export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
|
|
830
|
+
export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","diag_bridges","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "0.1.0",
|
|
3
|
-
"contractVersion": "1.
|
|
3
|
+
"contractVersion": "1.5.0",
|
|
4
|
+
"multiPortDiscovery": true,
|
|
4
5
|
"protocol": "tool_request/result",
|
|
5
6
|
"tools": [
|
|
6
7
|
{
|
|
@@ -539,6 +540,16 @@
|
|
|
539
540
|
},
|
|
540
541
|
"flags": {}
|
|
541
542
|
},
|
|
543
|
+
{
|
|
544
|
+
"name": "diag_bridges",
|
|
545
|
+
"summary": "List discovered xcsh bridges (port, tenant, env, sessionId, lastSeen) for multi-session diagnostics.",
|
|
546
|
+
"category": "read",
|
|
547
|
+
"params": {
|
|
548
|
+
"type": "object",
|
|
549
|
+
"properties": {}
|
|
550
|
+
},
|
|
551
|
+
"flags": {}
|
|
552
|
+
},
|
|
542
553
|
{
|
|
543
554
|
"name": "capture_login_flow",
|
|
544
555
|
"summary": "Diagnostic: captured login redirect chain annotated with tenant/env (Phase 0b).",
|
|
@@ -67,6 +67,25 @@ export function resolvePort(port?: number): number {
|
|
|
67
67
|
return DEFAULT_PORT;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Inclusive loopback discovery range for auto-selected bridge ports. */
|
|
71
|
+
export const PORT_RANGE_START = 19222;
|
|
72
|
+
export const PORT_RANGE_END = 19241;
|
|
73
|
+
|
|
74
|
+
/** Every port in the discovery range, lowest first. */
|
|
75
|
+
export function portCandidates(): number[] {
|
|
76
|
+
const out: number[] = [];
|
|
77
|
+
for (let p = PORT_RANGE_START; p <= PORT_RANGE_END; p++) out.push(p);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The explicitly forced port (arg → XCSH_BRIDGE_PORT), or null to auto-select. */
|
|
82
|
+
export function resolveForcedPort(port?: number): number | null {
|
|
83
|
+
if (typeof port === "number" && Number.isFinite(port) && port > 0) return port;
|
|
84
|
+
const env = Number(process.env.XCSH_BRIDGE_PORT);
|
|
85
|
+
if (Number.isFinite(env) && env > 0) return env;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
/**
|
|
71
90
|
* Loopback WebSocket server bridging xcsh to the Chrome extension. Speaks JSON
|
|
72
91
|
* over WS frames: requests `{type:"tool_request",...}`, replies
|
|
@@ -159,56 +178,58 @@ export class BridgeServer {
|
|
|
159
178
|
this.#resolveClient(channelId)?.send(JSON.stringify(payload));
|
|
160
179
|
}
|
|
161
180
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
181
|
+
#onOpen(ws: ServerWebSocket<undefined>): void {
|
|
182
|
+
// Assign a channel ID to each connection. For backwards compat (single
|
|
183
|
+
// extension), the first connection gets "default". Additional connections
|
|
184
|
+
// get "ch-1", "ch-2", etc. — supporting multi-tab parallelism.
|
|
185
|
+
const channelId = this.#clients.size === 0 ? "default" : `ch-${++this.#nextChannelIndex}`;
|
|
186
|
+
(ws as unknown as { channelId: string }).channelId = channelId;
|
|
187
|
+
this.#clients.set(channelId, ws);
|
|
188
|
+
// Start a heartbeat ping to keep the MV3 service worker alive.
|
|
189
|
+
// Chrome suspends idle SWs after ~30s; a ping every 15s prevents that.
|
|
190
|
+
if (!this.#heartbeat) {
|
|
191
|
+
this.#heartbeat = setInterval(() => {
|
|
192
|
+
for (const c of this.#clients.values()) {
|
|
193
|
+
try {
|
|
194
|
+
c.send(JSON.stringify({ type: "ping" }));
|
|
195
|
+
} catch {
|
|
196
|
+
/* client may have dropped */
|
|
176
197
|
}
|
|
177
198
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (!
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
/* client may have dropped */
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}, 15_000);
|
|
199
|
+
}, 15_000);
|
|
200
|
+
}
|
|
201
|
+
for (const cb of this.#onConnected) cb();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Try to bind the loopback WS server to `port`. Returns false on EADDRINUSE
|
|
205
|
+
* (so the caller can try the next candidate); rethrows any other error. */
|
|
206
|
+
listen(port: number, opts?: { skipOriginCheck?: boolean }): boolean {
|
|
207
|
+
try {
|
|
208
|
+
this.#server = Bun.serve({
|
|
209
|
+
port,
|
|
210
|
+
hostname: "127.0.0.1",
|
|
211
|
+
fetch: (req, server) => {
|
|
212
|
+
if (!opts?.skipOriginCheck) {
|
|
213
|
+
const origin = req.headers.get("origin") ?? "";
|
|
214
|
+
const { EXTENSION_ID } = require("../cli/chrome-cli");
|
|
215
|
+
if (origin !== `chrome-extension://${EXTENSION_ID}`) {
|
|
216
|
+
return new Response("Forbidden", { status: 403 });
|
|
217
|
+
}
|
|
201
218
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
message: (ws, message) => {
|
|
205
|
-
this.#handleMessage(ws, message);
|
|
219
|
+
if (server.upgrade(req)) return undefined;
|
|
220
|
+
return new Response("xcsh bridge: WebSocket only", { status: 426 });
|
|
206
221
|
},
|
|
207
|
-
|
|
208
|
-
this.#
|
|
222
|
+
websocket: {
|
|
223
|
+
open: ws => this.#onOpen(ws),
|
|
224
|
+
message: (ws, message) => this.#handleMessage(ws, message),
|
|
225
|
+
close: ws => this.#onClose(ws),
|
|
209
226
|
},
|
|
210
|
-
}
|
|
211
|
-
|
|
227
|
+
});
|
|
228
|
+
return true;
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e instanceof Error && /EADDRINUSE|address already in use|in use/i.test(e.message)) return false;
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
212
233
|
}
|
|
213
234
|
|
|
214
235
|
#handleMessage(ws: ServerWebSocket<undefined>, message: string | Buffer): void {
|
|
@@ -229,10 +250,12 @@ export class BridgeServer {
|
|
|
229
250
|
} else if (msg.type === "hello") {
|
|
230
251
|
// Identity handshake: tell the extension which tenant this process serves.
|
|
231
252
|
const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
|
|
253
|
+
const { EXTENSION_CONTRACT_VERSION } = require("./capabilities.generated");
|
|
232
254
|
ws.send(
|
|
233
255
|
JSON.stringify({
|
|
234
256
|
type: "hello_ack",
|
|
235
257
|
sessionId: this.#sessionId,
|
|
258
|
+
contractVersion: EXTENSION_CONTRACT_VERSION,
|
|
236
259
|
tenant: info.tenant,
|
|
237
260
|
env: info.env,
|
|
238
261
|
apiUrl: info.apiUrl,
|
|
@@ -293,12 +316,23 @@ export class BridgeServer {
|
|
|
293
316
|
}
|
|
294
317
|
|
|
295
318
|
/**
|
|
296
|
-
* Start the {@link BridgeServer} on the resolved loopback port
|
|
297
|
-
*
|
|
319
|
+
* Start the {@link BridgeServer} on the resolved loopback port. If a port is
|
|
320
|
+
* forced (explicit arg or `XCSH_BRIDGE_PORT`) it must be free — throws loudly
|
|
321
|
+
* if taken. Otherwise, auto-selects the lowest free port in the discovery range
|
|
322
|
+
* ({@link PORT_RANGE_START}–{@link PORT_RANGE_END}). The WebSocket transport
|
|
298
323
|
* needs no filesystem setup — Chrome connects directly to `ws://127.0.0.1:<port>`.
|
|
299
324
|
*/
|
|
300
325
|
export async function startBridgeServer(port?: number, opts?: { skipOriginCheck?: boolean }): Promise<BridgeServer> {
|
|
301
326
|
const server = new BridgeServer();
|
|
302
|
-
|
|
303
|
-
|
|
327
|
+
const forced = resolveForcedPort(port);
|
|
328
|
+
if (forced !== null) {
|
|
329
|
+
if (!server.listen(forced, opts)) {
|
|
330
|
+
throw new Error(`XCSH_BRIDGE_PORT ${forced} is already in use — free it or pick another port`);
|
|
331
|
+
}
|
|
332
|
+
return server;
|
|
333
|
+
}
|
|
334
|
+
for (const candidate of portCandidates()) {
|
|
335
|
+
if (server.listen(candidate, opts)) return server;
|
|
336
|
+
}
|
|
337
|
+
throw new Error(`no free xcsh bridge port in ${PORT_RANGE_START}-${PORT_RANGE_END} — is another app on the range?`);
|
|
304
338
|
}
|
|
@@ -501,7 +501,6 @@ export class ExtensionPageActions implements PageActions {
|
|
|
501
501
|
throw new Error(`waitFor "${selector}" timed out after ${ms}ms`);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
-
// biome-ignore lint/suspicious/useAwait: signature is async (PageActions contract)
|
|
505
504
|
async screenshot(_file: string): Promise<void> {
|
|
506
505
|
// Intentionally a no-op for the extension provider. CDP captureScreenshot
|
|
507
506
|
// transiently FREEZES the MV3 service worker; in observable mode the runner
|
package/src/cli/chrome-cli.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { acquirePage, type BrowserProviderStatus, CdpBrowserProvider } from "../browser";
|
|
10
|
-
import {
|
|
10
|
+
import { PORT_RANGE_END, PORT_RANGE_START, resolveForcedPort } from "../browser/extension-bridge";
|
|
11
11
|
|
|
12
12
|
type Settings = { get(key: string): unknown };
|
|
13
13
|
|
|
@@ -43,12 +43,17 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
|
|
|
43
43
|
if (action === "status") return renderStatus(await provider.status());
|
|
44
44
|
if (action === "setup") {
|
|
45
45
|
// The extension connects directly over a loopback WebSocket — no native-messaging
|
|
46
|
-
// host manifest to install.
|
|
47
|
-
|
|
46
|
+
// host manifest to install. A forced port is reported exactly; otherwise xcsh
|
|
47
|
+
// auto-selects the lowest free port in the discovery range at launch.
|
|
48
|
+
const forced = resolveForcedPort();
|
|
49
|
+
const where =
|
|
50
|
+
forced !== null
|
|
51
|
+
? `ws://127.0.0.1:${forced} (forced via XCSH_BRIDGE_PORT)`
|
|
52
|
+
: `the lowest free port in ${PORT_RANGE_START}-${PORT_RANGE_END} (printed in the xcsh startup banner)`;
|
|
48
53
|
return (
|
|
49
|
-
`The xcsh Chrome extension connects directly to xcsh over a loopback WebSocket on ` +
|
|
50
|
-
`
|
|
51
|
-
`Install/keep the xcsh Chrome extension from the Web Store
|
|
54
|
+
`The xcsh Chrome extension connects directly to xcsh over a loopback WebSocket on ${where}.\n` +
|
|
55
|
+
`The extension scans that range and links each tenant's xcsh automatically.\n` +
|
|
56
|
+
`Install/keep the xcsh Chrome extension from the Web Store:\n ${WEB_STORE_URL}`
|
|
52
57
|
);
|
|
53
58
|
}
|
|
54
59
|
// relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@f5-sales-demo/xcsh";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* herdr integration (bundled, default-on).
|
|
5
|
+
*
|
|
6
|
+
* Reports xcsh's live agent state to the herdr terminal multiplexer over its
|
|
7
|
+
* socket API so an xcsh pane shows up as a first-class "xcsh" assistant with an
|
|
8
|
+
* idle / working / blocked indicator.
|
|
9
|
+
*
|
|
10
|
+
* xcsh is a fork of pi; a user may have both installed, so this reporter always
|
|
11
|
+
* identifies itself as "xcsh" (never "pi") and claims pane authority via
|
|
12
|
+
* `--source xcsh` so herdr's passive pi-detection heuristics cannot mislabel the
|
|
13
|
+
* pane.
|
|
14
|
+
*
|
|
15
|
+
* The extension is completely inert unless it is running inside a herdr pane
|
|
16
|
+
* (detected via the `HERDR_PANE_ID` environment variable herdr injects), so it
|
|
17
|
+
* has zero effect for users who do not run xcsh under herdr.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const HERDR_AGENT_LABEL = "xcsh";
|
|
21
|
+
|
|
22
|
+
export default function herdrReporter(pi: ExtensionAPI): void {
|
|
23
|
+
const paneId = process.env.HERDR_PANE_ID;
|
|
24
|
+
if (!paneId) {
|
|
25
|
+
// Not running under herdr — do not register anything.
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let seq = 0;
|
|
30
|
+
// Tracks whether an interactive prompt is currently awaiting the user, so an
|
|
31
|
+
// agent_end that fires while a prompt is open is reported as blocked, not idle.
|
|
32
|
+
let promptOpen = false;
|
|
33
|
+
|
|
34
|
+
pi.setLabel(HERDR_AGENT_LABEL);
|
|
35
|
+
|
|
36
|
+
// Fire-and-forget: a missing or failing herdr CLI must never disturb the agent.
|
|
37
|
+
const runHerdr = (args: string[]): void => {
|
|
38
|
+
pi.exec("herdr", args).catch((err: unknown) => {
|
|
39
|
+
pi.logger.debug("herdr report failed", {
|
|
40
|
+
error: err instanceof Error ? err.message : String(err),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const report = (state: "idle" | "working" | "blocked"): void => {
|
|
46
|
+
runHerdr([
|
|
47
|
+
"pane",
|
|
48
|
+
"report-agent",
|
|
49
|
+
paneId,
|
|
50
|
+
"--source",
|
|
51
|
+
HERDR_AGENT_LABEL,
|
|
52
|
+
"--agent",
|
|
53
|
+
HERDR_AGENT_LABEL,
|
|
54
|
+
"--state",
|
|
55
|
+
state,
|
|
56
|
+
"--seq",
|
|
57
|
+
String(seq++),
|
|
58
|
+
]);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Announce presence as soon as the session is initialized.
|
|
62
|
+
pi.on("session_start", () => {
|
|
63
|
+
report("idle");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Busy while the agent loop is streaming a response.
|
|
67
|
+
pi.on("agent_start", () => {
|
|
68
|
+
report("working");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Back to idle when the loop ends — unless we are waiting on a user prompt.
|
|
72
|
+
pi.on("agent_end", () => {
|
|
73
|
+
report(promptOpen ? "blocked" : "idle");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// An interactive prompt (permission gate, ask tool, confirm/input) is
|
|
77
|
+
// awaiting the user: that is herdr's "needs attention" (blocked) state.
|
|
78
|
+
pi.on("user_prompt_start", () => {
|
|
79
|
+
promptOpen = true;
|
|
80
|
+
report("blocked");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pi.on("user_prompt_end", (_event, ctx) => {
|
|
84
|
+
promptOpen = false;
|
|
85
|
+
report(ctx.isIdle() ? "idle" : "working");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Relinquish pane authority so herdr stops showing xcsh once we exit.
|
|
89
|
+
pi.on("session_shutdown", () => {
|
|
90
|
+
runHerdr([
|
|
91
|
+
"pane",
|
|
92
|
+
"release-agent",
|
|
93
|
+
paneId,
|
|
94
|
+
"--source",
|
|
95
|
+
HERDR_AGENT_LABEL,
|
|
96
|
+
"--agent",
|
|
97
|
+
HERDR_AGENT_LABEL,
|
|
98
|
+
"--seq",
|
|
99
|
+
String(seq++),
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -20,6 +20,7 @@ import type { CustomMessage } from "../../session/messages";
|
|
|
20
20
|
import { EventBus } from "../../utils/event-bus";
|
|
21
21
|
import { getAllPluginExtensionPaths } from "../plugins/loader";
|
|
22
22
|
import { resolvePath } from "../utils";
|
|
23
|
+
import herdrReporter from "./bundled/herdr-reporter";
|
|
23
24
|
import type {
|
|
24
25
|
Extension,
|
|
25
26
|
ExtensionAPI,
|
|
@@ -480,6 +481,37 @@ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
|
|
|
480
481
|
/**
|
|
481
482
|
* Discover and load extensions from standard locations.
|
|
482
483
|
*/
|
|
484
|
+
/** Extensions bundled with xcsh and loaded by default (before user extensions). */
|
|
485
|
+
const BUNDLED_EXTENSIONS: ReadonlyArray<{ name: string; factory: ExtensionFactory }> = [
|
|
486
|
+
{ name: "herdr-reporter", factory: herdrReporter },
|
|
487
|
+
];
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Load the extensions that ship with xcsh. They share the runtime and event bus
|
|
491
|
+
* of the discovered user extensions and are prepended so user extensions keep the
|
|
492
|
+
* ability to override. A bundled extension that throws is recorded as an error
|
|
493
|
+
* but never aborts the overall load.
|
|
494
|
+
*/
|
|
495
|
+
async function loadBundledExtensions(
|
|
496
|
+
result: LoadExtensionsResult,
|
|
497
|
+
cwd: string,
|
|
498
|
+
eventBus: EventBus,
|
|
499
|
+
isDisabled: (name: string) => boolean,
|
|
500
|
+
): Promise<void> {
|
|
501
|
+
for (const { name, factory } of BUNDLED_EXTENSIONS) {
|
|
502
|
+
if (isDisabled(name)) continue;
|
|
503
|
+
try {
|
|
504
|
+
const extension = await loadExtensionFromFactory(factory, cwd, eventBus, result.runtime, `bundled:${name}`);
|
|
505
|
+
result.extensions.unshift(extension);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
result.errors.push({
|
|
508
|
+
path: `bundled:${name}`,
|
|
509
|
+
error: err instanceof Error ? err.message : String(err),
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
483
515
|
export async function discoverAndLoadExtensions(
|
|
484
516
|
configuredPaths: string[],
|
|
485
517
|
cwd: string,
|
|
@@ -568,5 +600,8 @@ export async function discoverAndLoadExtensions(
|
|
|
568
600
|
addPath(resolved);
|
|
569
601
|
}
|
|
570
602
|
|
|
571
|
-
|
|
603
|
+
const resolvedEventBus = eventBus ?? new EventBus();
|
|
604
|
+
const result = await loadExtensions(allPaths, cwd, resolvedEventBus);
|
|
605
|
+
await loadBundledExtensions(result, cwd, resolvedEventBus, isDisabledName);
|
|
606
|
+
return result;
|
|
572
607
|
}
|
|
@@ -541,6 +541,26 @@ export interface MessageEndEvent {
|
|
|
541
541
|
message: AgentMessage;
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
/** Kind of interactive prompt currently awaiting the user. */
|
|
545
|
+
export type UserPromptKind = "select" | "confirm" | "input";
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Fired when an interactive prompt (permission gate, `ask` tool, confirm/input
|
|
549
|
+
* dialog) is shown and is awaiting the user. Signals a "blocked / needs
|
|
550
|
+
* attention" state that is otherwise not observable from the agent event stream,
|
|
551
|
+
* since the session stays `isStreaming` while a prompt is open.
|
|
552
|
+
*/
|
|
553
|
+
export interface UserPromptStartEvent {
|
|
554
|
+
type: "user_prompt_start";
|
|
555
|
+
kind: UserPromptKind;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Fired when an interactive prompt resolves (answered, cancelled, or timed out). */
|
|
559
|
+
export interface UserPromptEndEvent {
|
|
560
|
+
type: "user_prompt_end";
|
|
561
|
+
kind: UserPromptKind;
|
|
562
|
+
}
|
|
563
|
+
|
|
544
564
|
/** Fired when a tool starts executing */
|
|
545
565
|
export interface ToolExecutionStartEvent {
|
|
546
566
|
type: "tool_execution_start";
|
|
@@ -817,6 +837,8 @@ export type ExtensionEvent =
|
|
|
817
837
|
| MessageStartEvent
|
|
818
838
|
| MessageUpdateEvent
|
|
819
839
|
| MessageEndEvent
|
|
840
|
+
| UserPromptStartEvent
|
|
841
|
+
| UserPromptEndEvent
|
|
820
842
|
| ToolExecutionStartEvent
|
|
821
843
|
| ToolExecutionUpdateEvent
|
|
822
844
|
| ToolExecutionEndEvent
|
|
@@ -1011,6 +1033,8 @@ export interface ExtensionAPI {
|
|
|
1011
1033
|
on(event: "message_start", handler: ExtensionHandler<MessageStartEvent>): void;
|
|
1012
1034
|
on(event: "message_update", handler: ExtensionHandler<MessageUpdateEvent>): void;
|
|
1013
1035
|
on(event: "message_end", handler: ExtensionHandler<MessageEndEvent>): void;
|
|
1036
|
+
on(event: "user_prompt_start", handler: ExtensionHandler<UserPromptStartEvent>): void;
|
|
1037
|
+
on(event: "user_prompt_end", handler: ExtensionHandler<UserPromptEndEvent>): void;
|
|
1014
1038
|
on(event: "tool_execution_start", handler: ExtensionHandler<ToolExecutionStartEvent>): void;
|
|
1015
1039
|
on(event: "tool_execution_update", handler: ExtensionHandler<ToolExecutionUpdateEvent>): void;
|
|
1016
1040
|
on(event: "tool_execution_end", handler: ExtensionHandler<ToolExecutionEndEvent>): void;
|
|
@@ -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.55.0",
|
|
21
|
+
"commit": "d4705fb3ced007a59e110d11971eb25c3dc9e83f",
|
|
22
|
+
"shortCommit": "d4705fb",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.55.0",
|
|
25
|
+
"commitDate": "2026-07-02T22:38:12Z",
|
|
26
|
+
"buildDate": "2026-07-02T23:02:06.036Z",
|
|
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/d4705fb3ced007a59e110d11971eb25c3dc9e83f",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.55.0"
|
|
33
33
|
};
|
package/src/main.ts
CHANGED
|
@@ -617,16 +617,14 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
617
617
|
const cwd = getProjectDir();
|
|
618
618
|
await logger.time("settings:init", Settings.init, { cwd });
|
|
619
619
|
|
|
620
|
-
// F5 XC context
|
|
621
|
-
//
|
|
622
|
-
// directly must call ContextService.init(configDir).loadActive() themselves.
|
|
620
|
+
// F5 XC context is session-scoped: nothing loads at startup. We still init the
|
|
621
|
+
// ContextService singleton so /context commands and the session bootstrap work.
|
|
623
622
|
try {
|
|
624
623
|
const { ContextService } = await import("./services/xcsh-context");
|
|
625
624
|
const { getXCSHConfigDir } = await import("@f5-sales-demo/pi-utils");
|
|
626
|
-
|
|
627
|
-
await contextService.loadActive(cwd);
|
|
625
|
+
ContextService.init(getXCSHConfigDir());
|
|
628
626
|
} catch {
|
|
629
|
-
//
|
|
627
|
+
// ContextService optional (SDK consumers / tests) — continue.
|
|
630
628
|
}
|
|
631
629
|
|
|
632
630
|
if (parsedArgs.mode === "rpc") {
|
|
@@ -816,6 +814,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
816
814
|
let sessionReady = false;
|
|
817
815
|
if (process.env.XCSH_BROWSER_PROVIDER?.toLowerCase() === "extension") {
|
|
818
816
|
bridgeServer = await startBridgeServer();
|
|
817
|
+
console.error(`[xcsh] extension bridge listening on ws://127.0.0.1:${bridgeServer.port}`);
|
|
819
818
|
// Make the bridge globally available so ALL selectProvider() calls reuse it
|
|
820
819
|
// (prevents starting a conflicting second bridge on the same port).
|
|
821
820
|
setSharedBridgeServer(bridgeServer);
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ExtensionWidgetContent,
|
|
14
14
|
ExtensionWidgetOptions,
|
|
15
15
|
TerminalInputHandler,
|
|
16
|
+
UserPromptKind,
|
|
16
17
|
} from "../../extensibility/extensions";
|
|
17
18
|
import { HookEditorComponent } from "../../modes/components/hook-editor";
|
|
18
19
|
import { HookInputComponent } from "../../modes/components/hook-input";
|
|
@@ -652,6 +653,15 @@ export class ExtensionUiController {
|
|
|
652
653
|
/**
|
|
653
654
|
* Show a selector for hooks.
|
|
654
655
|
*/
|
|
656
|
+
/**
|
|
657
|
+
* Notify extensions that an interactive prompt is awaiting the user
|
|
658
|
+
* (blocked / needs-attention) and, via the returned promise's settlement,
|
|
659
|
+
* when it resolves. Fire-and-forget: reporting must never disturb the prompt.
|
|
660
|
+
*/
|
|
661
|
+
#emitPromptSignal(type: "user_prompt_start" | "user_prompt_end", kind: UserPromptKind): void {
|
|
662
|
+
this.ctx.session.extensionRunner?.emit({ type, kind }).catch(() => {});
|
|
663
|
+
}
|
|
664
|
+
|
|
655
665
|
showHookSelector(
|
|
656
666
|
title: string,
|
|
657
667
|
options: string[],
|
|
@@ -713,6 +723,7 @@ export class ExtensionUiController {
|
|
|
713
723
|
this.ctx.editorContainer.addChild(this.ctx.hookSelector);
|
|
714
724
|
this.ctx.ui.setFocus(this.ctx.hookSelector);
|
|
715
725
|
this.ctx.ui.requestRender();
|
|
726
|
+
this.#emitPromptSignal("user_prompt_start", "select");
|
|
716
727
|
if (dialogOptions?.signal) {
|
|
717
728
|
if (dialogOptions.signal.aborted) {
|
|
718
729
|
onAbort();
|
|
@@ -720,7 +731,7 @@ export class ExtensionUiController {
|
|
|
720
731
|
dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
|
|
721
732
|
}
|
|
722
733
|
}
|
|
723
|
-
return promise;
|
|
734
|
+
return promise.finally(() => this.#emitPromptSignal("user_prompt_end", "select"));
|
|
724
735
|
}
|
|
725
736
|
/**
|
|
726
737
|
* Hide the hook selector.
|
|
@@ -786,6 +797,7 @@ export class ExtensionUiController {
|
|
|
786
797
|
this.ctx.editorContainer.addChild(this.ctx.hookInput);
|
|
787
798
|
this.ctx.ui.setFocus(this.ctx.hookInput);
|
|
788
799
|
this.ctx.ui.requestRender();
|
|
800
|
+
this.#emitPromptSignal("user_prompt_start", "input");
|
|
789
801
|
if (dialogOptions?.signal) {
|
|
790
802
|
if (dialogOptions.signal.aborted) {
|
|
791
803
|
onAbort();
|
|
@@ -793,7 +805,7 @@ export class ExtensionUiController {
|
|
|
793
805
|
dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
|
|
794
806
|
}
|
|
795
807
|
}
|
|
796
|
-
return promise;
|
|
808
|
+
return promise.finally(() => this.#emitPromptSignal("user_prompt_end", "input"));
|
|
797
809
|
}
|
|
798
810
|
|
|
799
811
|
/**
|
package/src/sdk.ts
CHANGED
|
@@ -771,6 +771,41 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
771
771
|
const existingSession = logger.time("loadSessionContext", () =>
|
|
772
772
|
deobfuscateSessionContext(sessionManager.buildSessionContext(), obfuscator),
|
|
773
773
|
);
|
|
774
|
+
|
|
775
|
+
// --- Session-scoped context bootstrap ------------------------------------
|
|
776
|
+
// No context is auto-loaded at startup. The session decides: a RESUMED session
|
|
777
|
+
// re-activates the context recorded in its context_change log; a NEW session
|
|
778
|
+
// smart-auto-binds (folder-local, else the single context, else ask). Loading
|
|
779
|
+
// is via the existing activate(); auth is validated but never blocks resume.
|
|
780
|
+
try {
|
|
781
|
+
const { ContextService } = await import("./services/xcsh-context");
|
|
782
|
+
const { resolveAutoBind, chooseSessionContext } = await import("./services/session-context-binding");
|
|
783
|
+
const svc = ContextService.instance; // inited in main.ts (throws for SDK/tests → caught)
|
|
784
|
+
if (!process.env.XCSH_API_URL) {
|
|
785
|
+
const bound = existingSession.activeContextName; // resumed binding, if any
|
|
786
|
+
const available = (await svc.listContexts()).map(c => c.name);
|
|
787
|
+
const folderContext = await svc.resolveFolderContextName(cwd);
|
|
788
|
+
const autoBind = resolveAutoBind({ kind: "cli", availableContexts: available, folderContext });
|
|
789
|
+
const choice = chooseSessionContext(bound, autoBind);
|
|
790
|
+
if ("activate" in choice) {
|
|
791
|
+
try {
|
|
792
|
+
await svc.activate(choice.activate); // fires onContextChange → records context_change
|
|
793
|
+
await svc.validateToken(); // authenticate; non-blocking
|
|
794
|
+
} catch (err) {
|
|
795
|
+
// Context deleted since last use, or auth failed → surface, never block.
|
|
796
|
+
logger.warn("XCSH: session context bootstrap could not fully activate", {
|
|
797
|
+
context: choice.activate,
|
|
798
|
+
error: String(err),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// choice.needsSelection / choice.none → leave unbound; the /context status
|
|
803
|
+
// line and tools already prompt "run /context activate".
|
|
804
|
+
}
|
|
805
|
+
} catch {
|
|
806
|
+
// ContextService not initialized (SDK consumers / tests) — skip bootstrap.
|
|
807
|
+
}
|
|
808
|
+
|
|
774
809
|
const existingBranch = logger.time("getSessionBranch", () => sessionManager.getBranch());
|
|
775
810
|
const hasExistingSession = existingBranch.length > 0;
|
|
776
811
|
const hasThinkingEntry = existingBranch.some(entry => entry.type === "thinking_level_change");
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decisions for session-scoped context binding. No I/O — the caller
|
|
3
|
+
* gathers inputs (available contexts, folder-local context, tenant key) and
|
|
4
|
+
* performs the activation. Key-agnostic so the deferred extension/daemon phase
|
|
5
|
+
* reuses it unchanged.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type SessionKind = "cli" | "extension";
|
|
9
|
+
|
|
10
|
+
export interface AutoBindInput {
|
|
11
|
+
kind: SessionKind;
|
|
12
|
+
/** Names of all available (global) contexts. */
|
|
13
|
+
availableContexts: string[];
|
|
14
|
+
/** CLI: the folder-local context name resolved from `.xcsh/`, if any. */
|
|
15
|
+
folderContext?: string | null;
|
|
16
|
+
/** Extension: the tenant|env key of the focused tab. */
|
|
17
|
+
tenantKey?: string | null;
|
|
18
|
+
/** Extension: contextName → its tenant|env key (derived from apiUrl). */
|
|
19
|
+
contextTenantKeys?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type AutoBindResult = { kind: "bind"; contextName: string } | { kind: "needsSelection" } | { kind: "none" };
|
|
23
|
+
|
|
24
|
+
/** Decide which context (if any) a NEW session should auto-bind. */
|
|
25
|
+
export function resolveAutoBind(input: AutoBindInput): AutoBindResult {
|
|
26
|
+
if (input.kind === "extension") {
|
|
27
|
+
if (!input.tenantKey) return { kind: "none" };
|
|
28
|
+
const match = Object.entries(input.contextTenantKeys ?? {}).find(([, key]) => key === input.tenantKey);
|
|
29
|
+
return match ? { kind: "bind", contextName: match[0] } : { kind: "needsSelection" };
|
|
30
|
+
}
|
|
31
|
+
// cli
|
|
32
|
+
if (input.folderContext) return { kind: "bind", contextName: input.folderContext };
|
|
33
|
+
if (input.availableContexts.length === 1) return { kind: "bind", contextName: input.availableContexts[0] };
|
|
34
|
+
if (input.availableContexts.length === 0) return { kind: "none" };
|
|
35
|
+
return { kind: "needsSelection" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SessionContextChoice = { activate: string } | { needsSelection: true } | { none: true };
|
|
39
|
+
|
|
40
|
+
/** Resume (boundContextName present) wins; otherwise fall back to the auto-bind result. */
|
|
41
|
+
export function chooseSessionContext(
|
|
42
|
+
boundContextName: string | undefined,
|
|
43
|
+
autoBind: AutoBindResult,
|
|
44
|
+
): SessionContextChoice {
|
|
45
|
+
if (boundContextName) return { activate: boundContextName };
|
|
46
|
+
if (autoBind.kind === "bind") return { activate: autoBind.contextName };
|
|
47
|
+
if (autoBind.kind === "needsSelection") return { needsSelection: true };
|
|
48
|
+
return { none: true };
|
|
49
|
+
}
|
|
@@ -363,6 +363,19 @@ export class ContextService {
|
|
|
363
363
|
return this.#activeContext?.defaultNamespace ?? null;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
/** The project-local (`.xcsh/`) context NAME for `cwd`, or null. No activation,
|
|
367
|
+
* no settings mutation — a pure lookup used by the session bootstrap. */
|
|
368
|
+
async resolveFolderContextName(cwd: string): Promise<string | null> {
|
|
369
|
+
try {
|
|
370
|
+
const resolver = new ContextResolver({ paths: xcshContextPaths });
|
|
371
|
+
const local = await resolver.resolve(cwd);
|
|
372
|
+
if (local && local.source === "local") return (local.context as XCSHContext).name ?? null;
|
|
373
|
+
} catch {
|
|
374
|
+
/* no local context */
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
366
379
|
async loadActive(cwd?: string): Promise<XCSHContext | null> {
|
|
367
380
|
// FR-102: XCSH_API_URL is the signal to skip context loading entirely.
|
|
368
381
|
// Subprocesses inherit process.env, so they already see the env vars directly.
|
|
@@ -429,19 +442,10 @@ export class ContextService {
|
|
|
429
442
|
// sync form keeps createContext/deleteContext race-free with no coordination.
|
|
430
443
|
await this.listContexts();
|
|
431
444
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
if (!contextName) {
|
|
437
|
-
const contexts = this.#listContextFiles();
|
|
438
|
-
if (contexts.length === 1) {
|
|
439
|
-
contextName = contexts[0].replace(/\.json$/, "");
|
|
440
|
-
autoActivated = true;
|
|
441
|
-
} else {
|
|
442
|
-
return null;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
+
const contextName = this.#readActiveContextName();
|
|
446
|
+
// Session-scoped: no auto-activate of a lone context. Without an explicit
|
|
447
|
+
// active_context pointer there is no global default to load.
|
|
448
|
+
if (!contextName) return null;
|
|
445
449
|
|
|
446
450
|
// Read the context JSON
|
|
447
451
|
const context = this.#readContext(contextName);
|
|
@@ -460,12 +464,6 @@ export class ContextService {
|
|
|
460
464
|
return null;
|
|
461
465
|
}
|
|
462
466
|
|
|
463
|
-
// Only persist active_context after the context validates
|
|
464
|
-
if (autoActivated) {
|
|
465
|
-
this.#atomicWrite(this.activeContextPath, contextName);
|
|
466
|
-
logger.debug("XCSH: auto-activated single context", { name: contextName });
|
|
467
|
-
}
|
|
468
|
-
|
|
469
467
|
this.#activeContext = context;
|
|
470
468
|
this.#applyToSettings(context);
|
|
471
469
|
// Detect mixed source: context loaded but some fields come from process.env
|
|
@@ -495,9 +493,6 @@ export class ContextService {
|
|
|
495
493
|
|
|
496
494
|
this.#assertCompatibleVersion(context);
|
|
497
495
|
|
|
498
|
-
// NFR-402: write active_context first — if it fails, don't update settings
|
|
499
|
-
this.#atomicWrite(this.activeContextPath, name);
|
|
500
|
-
|
|
501
496
|
if (this.#activeContext && this.#activeContext.name !== name) {
|
|
502
497
|
this.#previousContextName = this.#activeContext.name;
|
|
503
498
|
}
|