@f5xc-salesdemos/xcsh 18.55.0 → 18.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.55.0",
4
+ "version": "18.56.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -48,12 +48,12 @@
48
48
  "dependencies": {
49
49
  "@agentclientprotocol/sdk": "0.16.1",
50
50
  "@mozilla/readability": "^0.6",
51
- "@f5xc-salesdemos/xcsh-stats": "18.55.0",
52
- "@f5xc-salesdemos/pi-agent-core": "18.55.0",
53
- "@f5xc-salesdemos/pi-ai": "18.55.0",
54
- "@f5xc-salesdemos/pi-natives": "18.55.0",
55
- "@f5xc-salesdemos/pi-tui": "18.55.0",
56
- "@f5xc-salesdemos/pi-utils": "18.55.0",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.56.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.56.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.56.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.56.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.56.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.56.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.55.0",
21
- "commit": "c87217beee0abc34c5d21baa7a6ecbd3e1109630",
22
- "shortCommit": "c87217b",
20
+ "version": "18.56.0",
21
+ "commit": "d61f41711c0498bf98d02028b9b49159e3dcf415",
22
+ "shortCommit": "d61f417",
23
23
  "branch": "main",
24
- "tag": "v18.55.0",
25
- "commitDate": "2026-05-09T19:47:18Z",
26
- "buildDate": "2026-05-09T20:10:12.336Z",
24
+ "tag": "v18.56.0",
25
+ "commitDate": "2026-05-09T21:11:13Z",
26
+ "buildDate": "2026-05-09T21:32:38.103Z",
27
27
  "dirty": false,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
30
30
  "repoSlug": "f5xc-salesdemos/xcsh",
31
- "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/c87217beee0abc34c5d21baa7a6ecbd3e1109630",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.55.0"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/d61f41711c0498bf98d02028b9b49159e3dcf415",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.56.0"
33
33
  };
@@ -1,10 +1,16 @@
1
1
  Execute an F5 Distributed Cloud API call directly.
2
2
 
3
3
  Handles authentication, URL construction, and HTTP execution.
4
- Requires `F5XC_API_URL` and `F5XC_API_TOKEN` environment variables.
4
+ Credentials are resolved from the active context profile (`/context`). Environment variables
5
+ `F5XC_API_URL` and `F5XC_API_TOKEN` override context values when set.
6
+
7
+ Path parameters like `{namespace}` are auto-resolved from the active context when not
8
+ explicitly provided in `params`. For example, `{namespace}` resolves to the context's
9
+ default namespace (`F5XC_NAMESPACE`).
5
10
 
6
11
  Pass all path `{placeholder}` values via `params`, e.g. `{ namespace: "default", name: "example-lb", vh_name: "example-vh" }`.
7
12
  Body is sent for all methods except GET when `payload` is provided — including DELETE operations that require a body.
13
+ Payload values like `$F5XC_NAMESPACE` are auto-expanded from the active context.
8
14
 
9
15
  Use this tool after reading the API catalog to get the endpoint path and payload structure.
10
16
 
@@ -1,12 +1,27 @@
1
1
  import { SECRET_ENV_PATTERNS } from "../secrets/index";
2
- import { F5XC_API_TOKEN, F5XC_API_URL, F5XC_NAMESPACE, F5XC_TENANT } from "./f5xc-env";
2
+ import { F5XC_API_TOKEN, F5XC_API_URL, F5XC_CONTEXT_NAME, F5XC_NAMESPACE, F5XC_TENANT } from "./f5xc-env";
3
3
 
4
4
  /** Keys excluded from the system prompt context variables listing. */
5
- const PROMPT_HIDDEN: ReadonlySet<string> = new Set([F5XC_API_TOKEN, F5XC_API_URL, F5XC_TENANT, F5XC_NAMESPACE]);
5
+ const PROMPT_HIDDEN: ReadonlySet<string> = new Set([
6
+ F5XC_API_TOKEN,
7
+ F5XC_API_URL,
8
+ F5XC_TENANT,
9
+ F5XC_NAMESPACE,
10
+ F5XC_CONTEXT_NAME,
11
+ ]);
6
12
 
7
13
  /** Keys never expanded in payloads — credentials that must not leak into request bodies. */
8
14
  const PAYLOAD_HIDDEN: ReadonlySet<string> = new Set([F5XC_API_TOKEN, F5XC_API_URL]);
9
15
 
16
+ /**
17
+ * Bridge between F5 XC context profiles and the xcsh_api tool.
18
+ *
19
+ * Reads from `Settings.bash.environment` (populated by ContextService on profile
20
+ * activation) and provides credential resolution, path parameter auto-filling,
21
+ * and payload variable expansion. Consumed by:
22
+ * - `XcshApiTool` for credential and namespace resolution during API calls
23
+ * - `sdk.ts` for surfacing non-sensitive context vars in the system prompt
24
+ */
10
25
  export interface ContextEnv {
11
26
  /** Get a single env var value from bash.environment, or undefined. */
12
27
  get(key: string): string | undefined;
@@ -32,6 +47,9 @@ export interface ContextEnv {
32
47
  * matching SECRET_ENV_PATTERNS, and explicitly provided sensitiveKeys.
33
48
  */
34
49
  getNonSensitiveVars(): Record<string, string>;
50
+
51
+ /** Return the active context profile name, or undefined if not set. */
52
+ getContextName(): string | undefined;
35
53
  }
36
54
 
37
55
  export interface ContextEnvOptions {
@@ -63,6 +81,10 @@ export function createContextEnv(settings: { get(key: string): unknown }, option
63
81
  return bashEnv()[key];
64
82
  },
65
83
 
84
+ getContextName(): string | undefined {
85
+ return bashEnv()[F5XC_CONTEXT_NAME] || undefined;
86
+ },
87
+
66
88
  resolvePath(path: string, explicitParams?: Record<string, string>): string {
67
89
  let resolved = path;
68
90
 
@@ -8,6 +8,7 @@ import {
8
8
  deriveTenantFromUrl,
9
9
  F5XC_API_TOKEN,
10
10
  F5XC_API_URL,
11
+ F5XC_CONTEXT_NAME,
11
12
  F5XC_NAMESPACE,
12
13
  F5XC_TENANT,
13
14
  hasEnvOverride,
@@ -1328,6 +1329,9 @@ export class ContextService {
1328
1329
  if (tenant) merged[F5XC_TENANT] = tenant;
1329
1330
  }
1330
1331
 
1332
+ // Inject context profile name for API tool identity surfacing
1333
+ merged[F5XC_CONTEXT_NAME] = context.name;
1334
+
1331
1335
  // Inject all additional env vars from context.env map
1332
1336
  if (context.env) {
1333
1337
  for (const [key, value] of Object.entries(context.env)) {
@@ -9,6 +9,8 @@ export const F5XC_NAMESPACE = "F5XC_NAMESPACE" as const;
9
9
  export const F5XC_TENANT = "F5XC_TENANT" as const;
10
10
  export const F5XC_USERNAME = "F5XC_USERNAME" as const;
11
11
  export const F5XC_CONSOLE_PASSWORD = "F5XC_CONSOLE_PASSWORD" as const;
12
+ /** Active context profile name. Read-only metadata injected by ContextService. */
13
+ export const F5XC_CONTEXT_NAME = "F5XC_CONTEXT_NAME" as const;
12
14
 
13
15
  export const RESERVED_ENV_KEYS: ReadonlySet<string> = new Set([
14
16
  F5XC_NAMESPACE,
@@ -27,6 +27,7 @@ import { searchToolBm25Renderer } from "./search-tool-bm25";
27
27
  import { sshToolRenderer } from "./ssh";
28
28
  import { todoWriteToolRenderer } from "./todo-write";
29
29
  import { writeToolRenderer } from "./write";
30
+ import { xcshApiToolRenderer } from "./xcsh-api-renderer";
30
31
 
31
32
  type ToolRenderer = {
32
33
  renderCall: (args: unknown, options: RenderResultOptions, theme: Theme) => Component;
@@ -68,4 +69,5 @@ export const toolRenderers: Record<string, ToolRenderer> = {
68
69
  gh_run_watch: ghRunWatchToolRenderer as ToolRenderer,
69
70
  web_search: webSearchToolRenderer as ToolRenderer,
70
71
  write: writeToolRenderer as ToolRenderer,
72
+ xcsh_api: xcshApiToolRenderer as ToolRenderer,
71
73
  };
@@ -0,0 +1,147 @@
1
+ /**
2
+ * TUI renderer for the xcsh_api tool.
3
+ *
4
+ * Provides context-aware visualization for F5 XC API calls:
5
+ * - renderCall: method badge + path while request is pending
6
+ * - renderResult: status code badge + JSON body preview (collapsed/expanded)
7
+ */
8
+ import type { Component } from "@f5xc-salesdemos/pi-tui";
9
+ import { Text } from "@f5xc-salesdemos/pi-tui";
10
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
+ import type { Theme, ThemeColor } from "../modes/theme/theme";
12
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
13
+ import { formatErrorMessage, PREVIEW_LIMITS, replaceTabs } from "./render-utils";
14
+ import type { XcshApiToolDetails } from "./xcsh-api";
15
+
16
+ interface XcshApiRenderArgs {
17
+ method?: string;
18
+ path?: string;
19
+ params?: Record<string, string>;
20
+ payload?: unknown;
21
+ }
22
+
23
+ /** Map HTTP method to a theme color for the badge. */
24
+ function methodColor(method: string): ThemeColor {
25
+ switch (method) {
26
+ case "GET":
27
+ return "accent";
28
+ case "DELETE":
29
+ return "error";
30
+ default:
31
+ return "warning";
32
+ }
33
+ }
34
+
35
+ /** Map HTTP status code to a theme color. */
36
+ function statusColor(status: number): ThemeColor {
37
+ if (status < 300) return "success";
38
+ if (status < 400) return "warning";
39
+ return "error";
40
+ }
41
+
42
+ const COLLAPSED_BODY_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED;
43
+
44
+ export const xcshApiToolRenderer = {
45
+ renderCall(args: XcshApiRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
46
+ const method = args.method ?? "???";
47
+ const apiPath = args.path ?? "…";
48
+ const text = renderStatusLine(
49
+ {
50
+ icon: "pending",
51
+ title: "API",
52
+ description: apiPath,
53
+ badge: { label: method, color: methodColor(method) },
54
+ },
55
+ uiTheme,
56
+ );
57
+ return new Text(text, 0, 0);
58
+ },
59
+
60
+ renderResult(
61
+ result: { content: Array<{ type: string; text?: string }>; details?: XcshApiToolDetails; isError?: boolean },
62
+ options: RenderResultOptions,
63
+ uiTheme: Theme,
64
+ args?: XcshApiRenderArgs,
65
+ ): Component {
66
+ const details = result.details;
67
+ const method = details?.method ?? args?.method ?? "???";
68
+ const url = details?.url;
69
+ // Show resolved path (from URL) or the original template path
70
+ let displayPath = args?.path ?? "…";
71
+ if (url) {
72
+ try {
73
+ displayPath = new URL(url).pathname;
74
+ } catch {
75
+ // Malformed URL — fall through to args.path
76
+ }
77
+ }
78
+ const status = details?.status ?? 0;
79
+ const statusText = status > 0 ? `${status}` : "failed";
80
+
81
+ if (result.isError && !details) {
82
+ const errorText = result.content?.find(c => c.type === "text")?.text;
83
+ return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
84
+ }
85
+
86
+ const meta: string[] = [];
87
+ if (details?.contextName) meta.push(uiTheme.fg("muted", details.contextName));
88
+ if (details?.durationMs !== undefined) meta.push(uiTheme.fg("dim", `${details.durationMs}ms`));
89
+ const header = renderStatusLine(
90
+ {
91
+ title: "API",
92
+ description: displayPath,
93
+ badge: { label: `${method} ${statusText}`, color: status > 0 ? statusColor(status) : "error" },
94
+ meta: meta.length > 0 ? meta : undefined,
95
+ },
96
+ uiTheme,
97
+ );
98
+
99
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
100
+ // Split off the status line prefix (e.g. "200 OK\n\n") from the body
101
+ const bodyStart = textContent.indexOf("\n\n");
102
+ let body = bodyStart >= 0 ? textContent.slice(bodyStart + 2) : textContent;
103
+ // Format JSON bodies for readable TUI display (error bodies include guidance text and won't parse)
104
+ try {
105
+ body = JSON.stringify(JSON.parse(body.trim()), null, 2);
106
+ } catch {
107
+ // Not valid JSON — keep as-is
108
+ }
109
+ const bodyLines = body.trim() ? replaceTabs(body).split("\n") : [];
110
+
111
+ let cached: RenderCache | undefined;
112
+ return {
113
+ render(width: number): string[] {
114
+ const { expanded } = options;
115
+ const key = new Hasher().bool(expanded).u32(width).digest();
116
+ if (cached?.key === key) return cached.lines;
117
+
118
+ const lines: string[] = [header];
119
+
120
+ if (bodyLines.length > 0) {
121
+ if (expanded) {
122
+ for (const line of bodyLines) {
123
+ lines.push(truncateToWidth(uiTheme.fg("toolOutput", line), width, Ellipsis.Omit));
124
+ }
125
+ } else {
126
+ const maxLines = COLLAPSED_BODY_LINES;
127
+ const display = bodyLines.slice(0, maxLines);
128
+ const remaining = bodyLines.length - maxLines;
129
+ for (const line of display) {
130
+ lines.push(truncateToWidth(uiTheme.fg("toolOutput", line), width, Ellipsis.Omit));
131
+ }
132
+ if (remaining > 0) {
133
+ lines.push(uiTheme.fg("dim", `… (${remaining} more lines) (ctrl+o to expand)`));
134
+ }
135
+ }
136
+ }
137
+
138
+ cached = { key, lines };
139
+ return lines;
140
+ },
141
+ invalidate() {
142
+ cached = undefined;
143
+ },
144
+ };
145
+ },
146
+ mergeCallAndResult: true,
147
+ };
@@ -2,7 +2,7 @@ import type { AgentTool, AgentToolResult } from "@f5xc-salesdemos/pi-agent-core"
2
2
  import { prompt } from "@f5xc-salesdemos/pi-utils";
3
3
  import { type Static, Type } from "@sinclair/typebox";
4
4
  import xcshApiDescription from "../prompts/tools/xcsh-api.md" with { type: "text" };
5
- import { createContextEnv } from "../services/context-env";
5
+ import { type ContextEnv, createContextEnv } from "../services/context-env";
6
6
  import type { ToolSession } from ".";
7
7
 
8
8
  const xcshApiSchema = Type.Object({
@@ -28,6 +28,10 @@ export interface XcshApiToolDetails {
28
28
  url: string;
29
29
  method: string;
30
30
  requestId: string;
31
+ /** Round-trip duration in milliseconds. */
32
+ durationMs?: number;
33
+ /** Active context profile name, if available. */
34
+ contextName?: string;
31
35
  }
32
36
 
33
37
  type XcshApiResult = AgentToolResult<XcshApiToolDetails> & { isError?: boolean };
@@ -38,15 +42,23 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
38
42
  readonly description: string;
39
43
  readonly parameters = xcshApiSchema;
40
44
 
41
- #contextEnv: ReturnType<typeof createContextEnv>;
45
+ #contextEnv: ContextEnv;
46
+ /** Tracks the last API base for context-switch detection and TLS re-warm. */
47
+ #lastApiBase = "";
42
48
 
43
49
  constructor(session: ToolSession) {
44
50
  this.description = prompt.render(xcshApiDescription);
45
51
  this.#contextEnv = createContextEnv(session.settings);
46
52
 
53
+ this.#warmTls();
54
+ }
55
+
56
+ /** Pre-warm TLS connection to the current context's API endpoint. */
57
+ #warmTls(): void {
47
58
  const apiBase = this.#resolveApiBase();
48
59
  const apiToken = this.#resolveApiToken();
49
60
  if (apiBase && apiToken) {
61
+ this.#lastApiBase = apiBase;
50
62
  fetch(`${apiBase}/api/web/namespaces`, {
51
63
  method: "HEAD",
52
64
  headers: { Authorization: `APIToken ${apiToken}` },
@@ -62,25 +74,67 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
62
74
  return process.env.F5XC_API_TOKEN ?? this.#contextEnv.get("F5XC_API_TOKEN") ?? "";
63
75
  }
64
76
 
65
- async execute(_toolCallId: string, params: XcshApiParams): Promise<XcshApiResult> {
77
+ #errorResult(text: string, details?: XcshApiToolDetails): XcshApiResult {
78
+ return {
79
+ content: [{ type: "text", text }],
80
+ ...(details ? { details } : {}),
81
+ isError: true,
82
+ };
83
+ }
84
+
85
+ /** Context-aware guidance appended to HTTP error responses for common CRUD failures. */
86
+ #statusGuidance(status: number): string | null {
87
+ const ctx = this.#contextEnv.getContextName();
88
+ const ctxHint = ctx ? ` (context: \`${ctx}\`)` : "";
89
+ switch (status) {
90
+ case 401:
91
+ return `Token may be expired or invalid${ctxHint}. Run \`/context validate\` to check credentials, or \`/context create\` to set up a new context with fresh credentials.`;
92
+ case 403:
93
+ return `Access denied${ctxHint}. The API token may lack the required role or permission for this operation. Check the token's role assignments in the F5 XC console.`;
94
+ case 404: {
95
+ const ns = this.#contextEnv.get("F5XC_NAMESPACE") ?? "default";
96
+ return `Resource not found. Verify the resource name exists in namespace \`${ns}\`${ctxHint}. Use a GET list operation to check existing resources.`;
97
+ }
98
+ case 409:
99
+ return `Resource already exists${ctxHint}. Use PUT to replace the existing resource, or DELETE it first before creating a new one.`;
100
+ case 429:
101
+ return `API rate limit exceeded${ctxHint}. Wait briefly and retry the request.`;
102
+ default:
103
+ return null;
104
+ }
105
+ }
106
+
107
+ async execute(_toolCallId: string, params: XcshApiParams, signal?: AbortSignal): Promise<XcshApiResult> {
66
108
  const apiBase = this.#resolveApiBase();
109
+ // Detect context switch: API base changed since last call → re-warm TLS
110
+ if (apiBase && apiBase !== this.#lastApiBase) {
111
+ this.#warmTls();
112
+ }
67
113
  if (!apiBase) {
68
- return {
69
- content: [{ type: "text", text: "Error: F5XC_API_URL environment variable is not set." }],
70
- isError: true,
71
- };
114
+ const ctx = this.#contextEnv.getContextName();
115
+ const ctxNote = ctx ? ` Active context \`${ctx}\` has no API URL.` : "";
116
+ return this.#errorResult(
117
+ `Error: No API URL configured.${ctxNote} Activate a context with \`/context activate <name>\` or \`/context create\`, or set the F5XC_API_URL environment variable.`,
118
+ );
72
119
  }
73
-
74
120
  const apiToken = this.#resolveApiToken();
75
121
  if (!apiToken) {
76
- return {
77
- content: [{ type: "text", text: "Error: F5XC_API_TOKEN environment variable is not set." }],
78
- isError: true,
79
- };
122
+ const ctx = this.#contextEnv.getContextName();
123
+ const ctxNote = ctx ? ` Active context \`${ctx}\` has no API token.` : "";
124
+ return this.#errorResult(
125
+ `Error: No API token configured.${ctxNote} Activate a context with \`/context activate <name>\` or \`/context create\`, or set the F5XC_API_TOKEN environment variable.`,
126
+ );
80
127
  }
81
-
82
128
  const resolvedPath = this.#contextEnv.resolvePath(params.path, params.params);
83
129
 
130
+ // Guard: detect unresolved {placeholder} params still remaining in the path.
131
+ // Regex matches \w+ (same as ContextEnv.resolvePath) to avoid misaligned detection.
132
+ const unresolvedPlaceholders = resolvedPath.match(/\{\w+\}/g);
133
+ if (unresolvedPlaceholders) {
134
+ return this.#errorResult(
135
+ `Error: Unresolved path parameter(s): ${unresolvedPlaceholders.join(", ")}. Provide them via \`params\` or ensure they are configured in the active context.`,
136
+ );
137
+ }
84
138
  const url = `${apiBase}${resolvedPath}`;
85
139
  const requestId = crypto.randomUUID();
86
140
  const headers: Record<string, string> = {
@@ -89,10 +143,14 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
89
143
  "X-Request-ID": requestId,
90
144
  };
91
145
 
146
+ // Combine user abort signal with 30s timeout. User Ctrl+C is respected.
147
+ const timeoutSignal = AbortSignal.timeout(30_000);
148
+ const fetchSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
149
+
92
150
  const init: RequestInit = {
93
151
  method: params.method,
94
152
  headers,
95
- signal: AbortSignal.timeout(30_000),
153
+ signal: fetchSignal,
96
154
  };
97
155
 
98
156
  if (params.payload && params.method !== "GET") {
@@ -101,9 +159,11 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
101
159
  init.body = this.#contextEnv.resolvePayloadVars(payloadJson);
102
160
  }
103
161
 
162
+ const startMs = performance.now();
104
163
  try {
105
164
  const response = await fetch(url, init);
106
165
  const raw = await response.text();
166
+ const durationMs = Math.round(performance.now() - startMs);
107
167
  const contentType = response.headers.get("content-type") ?? "";
108
168
  let bodyText = raw;
109
169
  if (contentType.includes("application/json")) {
@@ -115,18 +175,41 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
115
175
  }
116
176
  const statusLine = `${response.status} ${response.statusText}`;
117
177
 
178
+ const contextName = this.#contextEnv.getContextName();
179
+ const detail = { status: response.status, url, method: params.method, requestId, durationMs, contextName };
180
+
181
+ // Context-aware CRUD error guidance for common HTTP status codes
182
+ const guidance = this.#statusGuidance(response.status);
183
+ if (guidance) {
184
+ return this.#errorResult(`${statusLine}\n\n${bodyText}\n\n${guidance}`, detail);
185
+ }
186
+
118
187
  return {
119
188
  content: [{ type: "text", text: `${statusLine}\n\n${bodyText}` }],
120
- details: { status: response.status, url, method: params.method, requestId },
189
+ details: detail,
121
190
  ...(response.status >= 400 ? { isError: true } : {}),
122
191
  };
123
192
  } catch (err) {
193
+ const durationMs = Math.round(performance.now() - startMs);
194
+ const contextName = this.#contextEnv.getContextName();
195
+ const detail = { status: 0, url, method: params.method, requestId, durationMs, contextName };
196
+ // Classify error: timeout vs DNS/network vs generic
197
+ const ctxLabel = contextName ? ` (context: \`${contextName}\`)` : "";
198
+ if (err instanceof Error && err.name === "AbortError") {
199
+ // User abort vs 30s timeout produce different AbortErrors
200
+ const message = signal?.aborted
201
+ ? "Request cancelled."
202
+ : `Request timed out after 30s${ctxLabel}. The API endpoint may be unreachable. Verify the API URL with \`/context show\`.`;
203
+ return this.#errorResult(message, detail);
204
+ }
124
205
  const message = err instanceof Error ? err.message : String(err);
125
- return {
126
- content: [{ type: "text", text: `Request failed: ${message}` }],
127
- details: { status: 0, url, method: params.method, requestId },
128
- isError: true,
129
- };
206
+ if (/ENOTFOUND|ECONNREFUSED|EAI_AGAIN|dns/i.test(message)) {
207
+ return this.#errorResult(
208
+ `Network error${ctxLabel}: ${message}. The API URL may be incorrect. Check with \`/context show\`.`,
209
+ detail,
210
+ );
211
+ }
212
+ return this.#errorResult(`Request failed${ctxLabel}: ${message}`, detail);
130
213
  }
131
214
  }
132
215
  }