@houndmcp/hound-mcp-pi 10.3.0 → 10.4.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.
@@ -1,569 +1,722 @@
1
- /**
2
- * Hound MCP extension for Pi agent.
3
- *
4
- * Six native tools, all routed through a single long-lived Hound MCP stdio
5
- * subprocess (singleton - Hound's startup prewarm persists for the whole
6
- * session, zero re-launch cost per call):
7
- * - web_fetch -> mcp_smart_fetch (auto anti-bot, PDF, archive fallback)
8
- * - web_search -> mcp_smart_search (10 keyless backends, consensus rank)
9
- * - web_crawl -> mcp_smart_crawl (best-first + sitemap-one-fetch map)
10
- * - web_screenshot -> mcp_screenshot (image capture for multimodal agents)
11
- * - cache_clear -> cache_clear (clear fetch cache)
12
- * - hound_version -> version (hound version + update status)
13
- *
14
- * Hound is fully keyless ($0, no API key, no account). The subprocess is
15
- * resolved from PATH so it tracks the installed Hound version automatically.
16
- *
17
- * Install: pip install hound-mcp[all]
18
- * Then: pi install git:github.com/dondai1234/master-fetch@v10.3.0
19
- */
20
-
21
- import { Type } from "typebox";
22
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
- import { Text } from "@earendil-works/pi-tui";
24
- import { spawn, execSync, type ChildProcess } from "node:child_process";
25
- import * as path from "node:path";
26
- import * as os from "node:os";
27
- import * as fs from "node:fs";
28
-
29
- // -- Hound executable resolution --
30
-
31
- function resolveHoundExe(): string | null {
32
- try {
33
- const cmd = process.platform === "win32" ? "where hound.exe" : "which hound";
34
- const out = execSync(cmd, {
35
- stdio: ["ignore", "pipe", "ignore"],
36
- timeout: 2000,
37
- windowsHide: true,
38
- }).toString().trim();
39
- const first = out.split(/\r?\n/)[0];
40
- if (first && fs.existsSync(first)) return first;
41
- } catch {}
42
- const fallback = path.join(
43
- os.homedir(),
44
- "AppData", "Local", "Programs", "Python", "Python311", "Scripts", "hound.exe",
45
- );
46
- return fs.existsSync(fallback) ? fallback : null;
47
- }
48
-
49
- const HOUND_EXE = resolveHoundExe();
50
- const INIT_TIMEOUT_MS = 15_000;
51
- const CALL_TIMEOUT_MS = 90_000;
52
- const CRAWL_TIMEOUT_MS = 150_000;
53
- const INIT_ATTEMPTS = 3;
54
- const INIT_BACKOFF_MS = 400;
55
-
56
- // -- MCP Stdio Client (singleton subprocess + JSON-RPC) --
57
- //
58
- // Hound is spawned once per Pi session and kept alive. Its startup prewarm
59
- // (stealthy browser + search-engine sessions + neural reranker ONNX load)
60
- // runs in the background during session_start, so by the time the agent
61
- // makes its first web_fetch/search call, Hound is already warm. A dead
62
- // subprocess (crash, OS kill) triggers a transparent re-init on the next
63
- // call - callers never see a raw spawn error, just a slightly slower call.
64
- //
65
- // The MCP initialize handshake has a hard timeout. Hound occasionally spawns
66
- // dead: its synchronous import playwright (via _get_scrapling) runs on the
67
- // single-threaded asyncio loop and freezes it so server.run can never write
68
- // the reply. The process is alive but mute - no stderr, no exit - so a longer
69
- // timeout cannot help. A fresh re-spawn reliably responds in ~2-3s, so we
70
- // kill the dead one and retry up to INIT_ATTEMPTS times.
71
-
72
- interface Pending {
73
- resolve: (v: any) => void;
74
- reject: (e: Error) => void;
75
- timer: NodeJS.Timeout;
76
- }
77
-
78
- class HoundClient {
79
- private proc: ChildProcess | null = null;
80
- private ready: Promise<boolean> | null = null;
81
- private initInFlight: Promise<boolean> | null = null;
82
- private nextId = 0;
83
- private pending = new Map<number, Pending>();
84
- private stdoutBuf = "";
85
- private lastStderr = "";
86
-
87
- async ensureReady(): Promise<boolean> {
88
- if (this.ready) return this.ready;
89
- if (!HOUND_EXE) return false;
90
- if (this.initInFlight) return this.initInFlight;
91
- const p = this._initWithRetry();
92
- this.initInFlight = p;
93
- p.then((ok) => {
94
- this.initInFlight = null;
95
- if (ok) this.ready = p;
96
- else this.ready = null;
97
- });
98
- return p;
99
- }
100
-
101
- private async _initWithRetry(): Promise<boolean> {
102
- for (let attempt = 1; attempt <= INIT_ATTEMPTS; attempt++) {
103
- if (await this._initOnce()) return true;
104
- if (attempt < INIT_ATTEMPTS) await new Promise((r) => setTimeout(r, INIT_BACKOFF_MS));
105
- }
106
- return false;
107
- }
108
-
109
- private _initOnce(): Promise<boolean> {
110
- return new Promise<boolean>((resolve) => {
111
- let settled = false;
112
- let initTimer: NodeJS.Timeout | undefined;
113
- let stderrBuf = "";
114
- const done = (ok: boolean) => {
115
- if (settled) return;
116
- settled = true;
117
- if (initTimer) clearTimeout(initTimer);
118
- if (!ok) this.kill();
119
- resolve(ok);
120
- };
121
- try {
122
- this.proc = spawn(HOUND_EXE!, [], {
123
- stdio: ["pipe", "pipe", "pipe"],
124
- windowsHide: true,
125
- env: { ...process.env },
126
- });
127
- } catch (e: any) {
128
- this.lastStderr = `spawn threw: ${e?.message ?? e}`;
129
- done(false);
130
- return;
131
- }
132
- this.proc.on("error", (e: any) => {
133
- this.lastStderr = `spawn error: ${e?.message ?? e}`;
134
- done(false);
135
- });
136
- this.proc.on("close", (code, signal) => {
137
- for (const [, { reject, timer }] of this.pending) {
138
- clearTimeout(timer);
139
- reject(new Error("Hound closed"));
140
- }
141
- this.pending.clear();
142
- if (!settled) this.lastStderr = `exited before init (code=${code} signal=${signal})`;
143
- this.ready = null;
144
- done(false);
145
- });
146
- this.proc.stderr?.on("data", (chunk: Buffer) => {
147
- stderrBuf += chunk.toString("utf-8");
148
- if (stderrBuf.length > 4000) stderrBuf = stderrBuf.slice(-4000);
149
- });
150
- this.proc.stdout?.on("data", (chunk: Buffer) => {
151
- this.stdoutBuf += chunk.toString("utf-8");
152
- this._drain();
153
- });
154
- initTimer = setTimeout(() => {
155
- this.lastStderr = `initialize timed out after ${INIT_TIMEOUT_MS}ms (dead spawn)`;
156
- done(false);
157
- }, INIT_TIMEOUT_MS);
158
- const id = ++this.nextId;
159
- this.pending.set(id, {
160
- resolve: () => {
161
- this._notify("notifications/initialized", {});
162
- this.lastStderr = "";
163
- done(true);
164
- },
165
- reject: (e: Error) => {
166
- this.lastStderr = `initialize rejected: ${e.message}`;
167
- done(false);
168
- },
169
- timer: initTimer,
170
- });
171
- try {
172
- this.proc.stdin!.write(
173
- JSON.stringify({
174
- jsonrpc: "2.0",
175
- method: "initialize",
176
- params: {
177
- protocolVersion: "2025-03-26",
178
- capabilities: {},
179
- clientInfo: { name: "pi-hound", version: "10.3.0" },
180
- },
181
- id,
182
- }) + "\n",
183
- );
184
- } catch (e: any) {
185
- this.pending.delete(id);
186
- this.lastStderr = `stdin write failed: ${e?.message ?? e}`;
187
- done(false);
188
- }
189
- });
190
- }
191
-
192
- private _drain() {
193
- let idx: number;
194
- while ((idx = this.stdoutBuf.indexOf("\n")) !== -1) {
195
- const line = this.stdoutBuf.slice(0, idx).trim();
196
- this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
197
- if (!line) continue;
198
- try {
199
- const msg = JSON.parse(line);
200
- if (msg.id != null && this.pending.has(msg.id)) {
201
- const { resolve, reject, timer } = this.pending.get(msg.id)!;
202
- this.pending.delete(msg.id);
203
- clearTimeout(timer);
204
- if (msg.error) reject(new Error(msg.error?.message ?? JSON.stringify(msg.error)));
205
- else resolve(msg.result);
206
- }
207
- } catch {}
208
- }
209
- }
210
-
211
- private _notify(method: string, params: any) {
212
- if (!this.proc || this.proc.killed) return;
213
- try {
214
- this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
215
- } catch {}
216
- }
217
-
218
- async call(name: string, args: Record<string, any>, timeoutMs = CALL_TIMEOUT_MS): Promise<any> {
219
- const ready = await this.ensureReady();
220
- if (!ready) {
221
- const hint = HOUND_EXE
222
- ? `Hound failed to start after ${INIT_ATTEMPTS} attempts. ${this.lastStderr || "Silent dead hang."} Run: hound --doctor`
223
- : "Hound not found. Install: pip install hound-mcp[all]";
224
- throw new Error(hint);
225
- }
226
- return new Promise((resolve, reject) => {
227
- if (!this.proc || this.proc.killed) {
228
- reject(new Error("Hound not running"));
229
- return;
230
- }
231
- const id = ++this.nextId;
232
- const timer = setTimeout(() => {
233
- this.pending.delete(id);
234
- reject(new Error(`Hound timeout: ${name}`));
235
- }, timeoutMs);
236
- this.pending.set(id, { resolve, reject, timer });
237
- try {
238
- this.proc.stdin!.write(
239
- JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: { name, arguments: args }, id }) + "\n",
240
- );
241
- } catch (e) {
242
- clearTimeout(timer);
243
- this.pending.delete(id);
244
- reject(e as Error);
245
- }
246
- });
247
- }
248
-
249
- kill() {
250
- if (this.proc && !this.proc.killed) {
251
- try { this.proc.stdin?.end(); } catch {}
252
- try { this.proc.kill(); } catch {}
253
- }
254
- this.proc = null;
255
- this.ready = null;
256
- this.initInFlight = null;
257
- for (const [, { timer }] of this.pending) clearTimeout(timer);
258
- this.pending.clear();
259
- }
260
- }
261
-
262
- const hound = new HoundClient();
263
-
264
- // -- Helpers --
265
-
266
- function getText(result: any): string {
267
- const content = result?.content ?? [];
268
- return content.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n") || "(no output)";
269
- }
270
-
271
- function getImages(result: any): any[] {
272
- const content = result?.content ?? [];
273
- return content.filter((c: any) => c?.type === "image");
274
- }
275
-
276
- function tryJson(text: string): any {
277
- try { return JSON.parse(text); } catch { return {}; }
278
- }
279
-
280
- function pick(params: Record<string, any>, keys: string[]): Record<string, any> {
281
- const out: Record<string, any> = {};
282
- for (const k of keys) {
283
- if (params[k] !== undefined) out[k] = params[k];
284
- }
285
- return out;
286
- }
287
-
288
- // -- Extension --
289
-
290
- export default function (pi: ExtensionAPI) {
291
- pi.on("session_start", () => { hound.ensureReady().catch(() => {}); });
292
- pi.on("session_shutdown", () => { hound.kill(); });
293
-
294
- // -- web_fetch --
295
- pi.registerTool({
296
- name: "web_fetch",
297
- label: "Web Fetch",
298
- description: "Fetch a URL (or urls=[...] for parallel bulk). Auto HTTP -> stealthy escalation. Returns extracted text + metadata + signals: content_ok, next_action, summary, page_type, content_age_days/is_stale, source_type/is_official, source/archived_at. Hard-block (404/bot/auth) -> auto-recover from Internet Archive (source=archive.org, archived_at=snapshot date; archive_fallback=false in options to opt out). PDFs -> structured markdown + ToC + page ranges + auto-OCR. Long pages: paginate with offset/next_offset or focus='query' for only relevant blocks. actions=[...] for click/form/scroll. include_links/include_media via options.",
299
- promptSnippet: "web_fetch(url|urls, extraction_type, css_selector, focus, actions, include_links, pages, offset) - anti-bot fetch + clean extraction; paginates with next_offset; dead-link recovery from Internet Archive.",
300
- parameters: Type.Object({
301
- url: Type.Optional(Type.String({ description: "URL to fetch" })),
302
- urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs (parallel; returns per-URL results)" })),
303
- extraction_type: Type.Optional(Type.String({ description: "Content format (default markdown). html = raw HTML." })),
304
- css_selector: Type.Optional(Type.String({ description: "CSS selector to narrow extracted content (e.g. 'article', '.main'). Token saver." })),
305
- max_content_chars: Type.Optional(Type.Integer({ description: "Max chars of extracted content (default 40000, min 500). Lower = less context; rest paginated via offset/next_offset." })),
306
- timeout: Type.Optional(Type.Integer({ description: "Max request time in ms (default 30000)." })),
307
- cache_ttl: Type.Optional(Type.Integer({ description: "Cache seconds (default 3600). 0 = force fresh." })),
308
- force_fetcher: Type.Optional(Type.String({ description: "Pin to one tier, skip auto-escalation. 'http' = fast HTTP-only (fails on JS/bot walls). 'stealthy' = anti-detect browser. Default = auto." })),
309
- offset: Type.Optional(Type.Integer({ description: "Char offset into extracted text to resume a truncated page. Use next_offset from previous response." })),
310
- pages: Type.Optional(Type.String({ description: "PDF only: page spec like '1-5' or '1,3,5-7'. Use table_of_contents page/end_page ranges to pick. None = all pages." })),
311
- password: Type.Optional(Type.String({ description: "PDF only: password for an encrypted PDF." })),
312
- focus: Type.Optional(Type.String({ description: "Query-focused extraction: only BM25-relevant blocks returned. Context saver on long pages. Post-cache (no re-fetch). Re-pass same focus when paginating." })),
313
- actions: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }), { description: "Page interactions on stealthy browser AFTER load, BEFORE extraction. Forces stealthy + bypasses cache. Each item: {click:'css'}, {fill:{selector:'css',text:'x'}}, {press:'Enter'}, {wait:500}, {scroll:3}, {wait_selector:'css'}." })),
314
- options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "include_links (bool,false: response.links=citations/navigation/external+primary_source), include_media (bool,false: up to 20 page image URLs), archive_fallback (bool,true: recover from Internet Archive on hard-block; false=raw failure), proxy, cookies, extra_headers, useragent, wait, network_idle, headless, respect_robots, real_chrome/solve_cloudflare/block_webrtc/hide_canvas/main_content_only/use_trafilatura (anti-detect tuning, good defaults, rarely needed)." })),
315
- }),
316
- async execute(_id, params, _signal, _onUpdate, _ctx) {
317
- try {
318
- const args = pick(params, ["url", "urls", "extraction_type", "css_selector", "max_content_chars", "timeout", "cache_ttl", "force_fetcher", "offset", "pages", "password", "focus", "actions", "options"]);
319
- const result = await hound.call("mcp_smart_fetch", args, CALL_TIMEOUT_MS);
320
- const text = getText(result);
321
- const parsed = tryJson(text);
322
- if (Array.isArray(parsed.results)) {
323
- const ok = parsed.successful ?? 0;
324
- const total = parsed.total ?? parsed.results.length;
325
- const joined = parsed.results.map((r: any) => `# ${r.url}\n${(r.content ?? []).join("\n")}`).join("\n\n---\n\n");
326
- return { content: [{ type: "text", text: joined + `\n\n[${ok}/${total} OK]` }], details: { bulk: true, ok, total } };
327
- }
328
- const content = Array.isArray(parsed.content) ? parsed.content.join("\n") : text;
329
- const foot: string[] = [];
330
- if (parsed.summary) foot.push(parsed.summary);
331
- if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
332
- if (parsed.content_ok === false) foot.push("WARNING: content_ok=false - content may be a JS shell/login wall/error page");
333
- const trunc = parsed.is_truncated ? ` | next_offset=${parsed.next_offset}` : "";
334
- const fetcher = parsed.fetcher_used ?? "";
335
- const src = parsed.source === "archive.org" ? ` | ARCHIVE ${parsed.archived_at ?? ""}` : "";
336
- return {
337
- content: [{ type: "text", text: content + (foot.length ? `\n\n${foot.join("\n")}` : "") + `\n[${fetcher}${trunc}${src}]` }],
338
- details: { url: args.url, chars: content.length, content_ok: parsed.content_ok, truncated: !!parsed.is_truncated, source: parsed.source ?? "live" },
339
- };
340
- } catch (e: any) {
341
- return { content: [{ type: "text", text: `web_fetch error: ${e.message}` }], details: { error: e.message } };
342
- }
343
- },
344
- renderCall(args, theme) {
345
- const urlStr = (args.url ?? args.urls?.[0] ?? "").toString();
346
- const trunc = urlStr.length > 60 ? urlStr.slice(0, 57) + "..." : urlStr;
347
- return new Text(theme.fg("toolTitle", theme.bold("Web Fetch: ")) + theme.fg("accent", trunc), 0, 0);
348
- },
349
- renderResult(result, { isPartial }, theme) {
350
- if (isPartial) return new Text(theme.fg("dim", "fetching..."), 0, 0);
351
- const d = result.details as any;
352
- if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
353
- if (d?.bulk) return new Text(theme.fg("accent", `${d.ok}/${d.total} URLs`), 0, 0);
354
- const kb = ((d?.chars ?? 0) / 1024).toFixed(1);
355
- const src = d?.source === "archive.org" ? " (archive)" : "";
356
- const warn = d?.content_ok === false ? " !" : "";
357
- return new Text(theme.fg("accent", `${kb}KB${src}${warn}`), 0, 0);
358
- },
359
- });
360
-
361
- // -- web_search --
362
- pi.registerTool({
363
- name: "web_search",
364
- label: "Web Search",
365
- description: "Keyless web search (no API key). 10 backends in parallel (ddg,brave,mojeek,yahoo,yandex,startpage,google,qwant + opt-in wikipedia,grokipedia), neural-reranked + cross-backend consensus. Returns URLs + ranking, NOT content -> web_fetch the ones that match. Each result: relevance_score + fetch_relevance (high/med/low) + engines_consensus. related_queries from result titles+snippets. Blocked backends circuit-broken 60s. NEVER answer from snippets alone. Filters in options: site, exclude_sites, location, language, region, page, freshness.",
366
- promptSnippet: "web_search(query) - keyless web search across 10 backends; returns ranked URLs (not content), related_queries, consensus. web_fetch the results that match.",
367
- parameters: Type.Object({
368
- query: Type.String({ description: "Search query" }),
369
- options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "max_results (1-50,6), cache_ttl (300), mode (auto|neural|find_similar), engines (list), site, exclude_sites, location, language, region, page, freshness, url (for find_similar)" })),
370
- }),
371
- async execute(_id, params, _signal, _onUpdate, _ctx) {
372
- try {
373
- const args: Record<string, any> = { query: params.query };
374
- if (params.options && Object.keys(params.options).length) args.options = params.options;
375
- const result = await hound.call("mcp_smart_search", args, 60_000);
376
- const text = getText(result);
377
- const parsed = tryJson(text);
378
- const results: any[] = parsed.results ?? [];
379
- const body = results.length === 0
380
- ? `No results for "${params.query}".`
381
- : results.map((r: any, i: number) => {
382
- const cons = r.engines_consensus ? ` [consensus ${r.engines_consensus}]` : "";
383
- const tier = r.fetch_relevance ? ` (${r.fetch_relevance})` : "";
384
- return `[${i + 1}]${tier} ${r.title || "(untitled)"}${cons}\n ${r.url || ""}\n ${r.snippet || ""}`;
385
- }).join("\n\n");
386
- const foot: string[] = [];
387
- if (Array.isArray(parsed.related_queries) && parsed.related_queries.length)
388
- foot.push(`Related: ${parsed.related_queries.slice(0, 6).map((q: string) => `"${q}"`).join(", ")}`);
389
- if (Array.isArray(parsed.engine_blocked) && parsed.engine_blocked.length)
390
- foot.push(`Blocked: ${parsed.engine_blocked.join(", ")}`);
391
- if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
392
- return {
393
- content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
394
- details: { query: params.query, count: results.length, related: parsed.related_queries ?? [], blocked: parsed.engine_blocked ?? [] },
395
- };
396
- } catch (e: any) {
397
- return { content: [{ type: "text", text: `web_search error: ${e.message}` }], details: { error: e.message } };
398
- }
399
- },
400
- renderCall(args, theme) {
401
- const q = (args.query ?? "").toString();
402
- const trunc = q.length > 50 ? q.slice(0, 47) + "..." : q;
403
- return new Text(theme.fg("toolTitle", theme.bold("Web Search: ")) + theme.fg("accent", `"${trunc}"`), 0, 0);
404
- },
405
- renderResult(result, { isPartial }, theme) {
406
- if (isPartial) return new Text(theme.fg("dim", "searching..."), 0, 0);
407
- const d = result.details as any;
408
- if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
409
- if (!d?.count) return new Text(theme.fg("dim", "no results"), 0, 0);
410
- const rel = d.related?.length ? ` + ${d.related.length} related` : "";
411
- return new Text(theme.fg("accent", `${d.count} results${rel}`), 0, 0);
412
- },
413
- });
414
-
415
- // -- web_crawl --
416
- pi.registerTool({
417
- name: "web_crawl",
418
- label: "Web Crawl",
419
- description: "Deep-crawl a site: best-first same-domain walk, each page as markdown + content_ok + page_type. List pages -> structured link list. sitemap=true (in options) maps whole site from sitemap.xml in one fetch; sitemap='auto' = use if present else BFS. discover_only=true = URL map only. focus='query' prioritizes relevant pages + focus-filters each. crawl_urls=[...] fetches a chosen subset. Caps: max_pages (10), max_depth (2), max_total_chars, deadline_ms. Reuses web_fetch anti-bot + cache.",
420
- promptSnippet: "web_crawl(url, focus, options.sitemap, discover_only, crawl_urls, max_pages) - site crawl; sitemap=true maps a whole site in one fetch; crawl_urls fetches a chosen subset.",
421
- parameters: Type.Object({
422
- url: Type.String({ description: "Start URL (crawl stays on this domain)" }),
423
- focus: Type.Optional(Type.String({ description: "Query: prioritize crawling links relevant to this + focus-filter each page. Token saver on doc sites." })),
424
- discover_only: Type.Optional(Type.Boolean({ description: "true = return URL map only, no page content. For big sites prefer options sitemap=true." })),
425
- crawl_urls: Type.Optional(Type.Array(Type.String(), { description: "Chosen subset of URLs to fetch (second-phase selective crawl, no re-discovery). Use after sitemap=true or discover_only=true." })),
426
- options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "sitemap (true|'auto'|false,false: true=map from sitemap.xml in one fetch; 'auto'=use if present else BFS), max_pages (1-100,10), max_depth (0-5,2), path_include (list of path prefixes), path_exclude (list to skip), max_content_chars_per (8000), max_total_chars (token budget), concurrency (1-5,3), cache_ttl (3600;0=fresh), respect_robots (false), force_fetcher ('http'|'stealthy'), timeout (ms,30000), deadline_ms (120000)." })),
427
- }),
428
- async execute(_id, params, _signal, _onUpdate, _ctx) {
429
- try {
430
- const args = pick(params, ["url", "focus", "discover_only", "crawl_urls", "options"]);
431
- const result = await hound.call("mcp_smart_crawl", args, CRAWL_TIMEOUT_MS);
432
- const text = getText(result);
433
- const parsed = tryJson(text);
434
- const pages: any[] = Array.isArray(parsed.pages) ? parsed.pages : [];
435
- let body: string;
436
- if (parsed.sitemap_used || (args.discover_only && pages.length)) {
437
- body = pages.map((p: any, i: number) => `[${i + 1}] ${p.url}${p.lastmod ? ` (${p.lastmod})` : ""}`).join("\n");
438
- } else {
439
- body = pages.map((p: any) => {
440
- const tag = p.page_type ? `[${p.page_type}]` : "";
441
- const ok = p.content_ok ? "" : " WARNING";
442
- const c = Array.isArray(p.content) ? p.content.join("\n") : "";
443
- return `## ${p.url} ${tag}${ok}\n${c}`;
444
- }).join("\n\n---\n\n");
445
- }
446
- const foot: string[] = [];
447
- if (parsed.summary) foot.push(parsed.summary);
448
- if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
449
- return {
450
- content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
451
- details: { url: params.url, pages: pages.length, sitemap: !!parsed.sitemap_used },
452
- };
453
- } catch (e: any) {
454
- return { content: [{ type: "text", text: `web_crawl error: ${e.message}` }], details: { error: e.message } };
455
- }
456
- },
457
- renderCall(args, theme) {
458
- const urlStr = (args.url ?? "").toString();
459
- const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
460
- const sm = args.options?.sitemap ? " (sitemap)" : "";
461
- return new Text(theme.fg("toolTitle", theme.bold("Web Crawl: ")) + theme.fg("accent", trunc + sm), 0, 0);
462
- },
463
- renderResult(result, { isPartial }, theme) {
464
- if (isPartial) return new Text(theme.fg("dim", "crawling..."), 0, 0);
465
- const d = result.details as any;
466
- if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
467
- const sm = d.sitemap ? " (sitemap)" : "";
468
- return new Text(theme.fg("accent", `${d.pages} pages${sm}`), 0, 0);
469
- },
470
- });
471
-
472
- // -- web_screenshot --
473
- pi.registerTool({
474
- name: "web_screenshot",
475
- label: "Web Screenshot",
476
- description: "Screenshot a URL as an image. Multimodal agents only (content as images/canvas/visual layout). Text agents: use web_fetch. Stealthy browser auto-managed.",
477
- promptSnippet: "web_screenshot(url, full_page, image_type) - anti-bot one-shot screenshot of a URL (stealthy browser). Multimodal agents only.",
478
- parameters: Type.Object({
479
- url: Type.String({ description: "URL to screenshot" }),
480
- options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "full_page (bool,false), image_type (png|jpeg,png), quality (0-100,jpeg), wait (ms), wait_selector (css), network_idle (bool), timeout (ms,30000)." })),
481
- }),
482
- async execute(_id, params, _signal, _onUpdate, _ctx) {
483
- try {
484
- const args: Record<string, any> = { url: params.url };
485
- if (params.options && Object.keys(params.options).length) args.options = params.options;
486
- const result = await hound.call("mcp_screenshot", args, CALL_TIMEOUT_MS);
487
- const images = getImages(result);
488
- if (images.length) {
489
- const out: any[] = [];
490
- for (const img of images) out.push({ type: "image", data: img.data, mimeType: img.mimeType || "image/png" });
491
- out.push({ type: "text", text: `Screenshot captured for ${params.url}` });
492
- return { content: out, details: { url: params.url, images: images.length } };
493
- }
494
- const parsed = tryJson(getText(result));
495
- return {
496
- content: [{ type: "text", text: parsed.error ? `Capture failed: ${parsed.error}` : `Screenshot captured for ${params.url} (no image payload)` }],
497
- details: { url: params.url, images: 0, error: parsed.error ?? "" },
498
- };
499
- } catch (e: any) {
500
- return { content: [{ type: "text", text: `web_screenshot error: ${e.message}` }], details: { error: e.message } };
501
- }
502
- },
503
- renderCall(args, theme) {
504
- const urlStr = (args.url ?? "").toString();
505
- const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
506
- return new Text(theme.fg("toolTitle", theme.bold("Web Screenshot: ")) + theme.fg("accent", trunc), 0, 0);
507
- },
508
- renderResult(result, { isPartial }, theme) {
509
- if (isPartial) return new Text(theme.fg("dim", "capturing..."), 0, 0);
510
- const d = result.details as any;
511
- if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
512
- if (!d?.images) return new Text(theme.fg("dim", "no image"), 0, 0);
513
- return new Text(theme.fg("accent", `${d.images} image`), 0, 0);
514
- },
515
- });
516
-
517
- // -- cache_clear --
518
- pi.registerTool({
519
- name: "cache_clear",
520
- label: "Clear Cache",
521
- description: "Clear fetch cache. all=true wipes all (default: expired only). To re-fetch one URL fresh, pass cache_ttl=0 to web_fetch/web_crawl instead. Cache stores extracted text per URL+extraction_type+css_selector+pages (+ per query+filters for search); default TTL 1hr.",
522
- promptSnippet: "cache_clear(all) - clear fetch cache. all=true wipes all (default: expired only). For one URL fresh, use cache_ttl=0 on web_fetch instead.",
523
- parameters: Type.Object({
524
- all: Type.Optional(Type.Boolean({ description: "Wipe all (default: expired only)" })),
525
- }),
526
- async execute(_id, params, _signal, _onUpdate, _ctx) {
527
- try {
528
- const args = { all: params.all ?? false };
529
- const result = await hound.call("cache_clear", args, 10_000);
530
- return { content: [{ type: "text", text: getText(result) }], details: { all: args.all } };
531
- } catch (e: any) {
532
- return { content: [{ type: "text", text: `cache_clear error: ${e.message}` }], details: { error: e.message } };
533
- }
534
- },
535
- renderCall(_args, theme) {
536
- return new Text(theme.fg("toolTitle", theme.bold("Clear Cache")), 0, 0);
537
- },
538
- renderResult(result, _meta, theme) {
539
- const d = result.details as any;
540
- if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
541
- return new Text(theme.fg("accent", "cleared"), 0, 0);
542
- },
543
- });
544
-
545
- // -- hound_version --
546
- pi.registerTool({
547
- name: "hound_version",
548
- label: "Hound Version",
549
- description: "Hound version + update status.",
550
- promptSnippet: "hound_version() - hound version + update status.",
551
- parameters: Type.Object({}),
552
- async execute(_id, _params, _signal, _onUpdate, _ctx) {
553
- try {
554
- const result = await hound.call("version", {}, 10_000);
555
- return { content: [{ type: "text", text: getText(result) }], details: {} };
556
- } catch (e: any) {
557
- return { content: [{ type: "text", text: `hound_version error: ${e.message}` }], details: { error: e.message } };
558
- }
559
- },
560
- renderCall(_args, theme) {
561
- return new Text(theme.fg("toolTitle", theme.bold("Hound Version")), 0, 0);
562
- },
563
- renderResult(result, _meta, theme) {
564
- const d = result.details as any;
565
- if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
566
- return new Text(theme.fg("accent", "ok"), 0, 0);
567
- },
568
- });
569
- }
1
+ /**
2
+ * Hound MCP extension for Pi agent.
3
+ *
4
+ * Six native tools, all routed through a single long-lived Hound MCP stdio
5
+ * subprocess (singleton - Hound's startup prewarm persists for the whole
6
+ * session, zero re-launch cost per call):
7
+ * - web_fetch -> mcp_smart_fetch (auto anti-bot, PDF, archive fallback)
8
+ * - web_search -> mcp_smart_search (10 keyless backends, consensus rank)
9
+ * - web_crawl -> mcp_smart_crawl (best-first + sitemap-one-fetch map)
10
+ * - web_screenshot -> mcp_screenshot (image capture for multimodal agents)
11
+ * - cache_clear -> cache_clear (clear fetch cache)
12
+ * - hound_version -> version (hound version + update status)
13
+ *
14
+ * Hound is fully keyless ($0, no API key, no account). The subprocess is
15
+ * resolved from PATH so it tracks the installed Hound version automatically.
16
+ *
17
+ * Install: pip install hound-mcp[all]
18
+ * Then: pi install npm:@houndmcp/hound-mcp-pi
19
+ */
20
+
21
+ import { Type } from "typebox";
22
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
+ import { Text } from "@earendil-works/pi-tui";
24
+ import { spawn, execSync, type ChildProcess } from "node:child_process";
25
+ import * as path from "node:path";
26
+ import * as os from "node:os";
27
+ import * as fs from "node:fs";
28
+
29
+ // -- Version (read from package.json at load time) --
30
+
31
+ function readPkgVersion(): string {
32
+ try {
33
+ const pkgPath = path.join(__dirname, "..", "package.json");
34
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
35
+ return pkg.version || "unknown";
36
+ } catch {
37
+ return "unknown";
38
+ }
39
+ }
40
+
41
+ const EXTENSION_VERSION = readPkgVersion();
42
+
43
+ // -- Hound executable resolution (lazy, re-tried on each ensureReady if null) --
44
+
45
+ function resolveHoundExe(): string | null {
46
+ // 1. PATH lookup (covers 99% of installs)
47
+ try {
48
+ const cmd = process.platform === "win32" ? "where hound.exe" : "which hound";
49
+ const out = execSync(cmd, {
50
+ stdio: ["ignore", "pipe", "ignore"],
51
+ timeout: 2000,
52
+ windowsHide: true,
53
+ }).toString().trim();
54
+ const first = out.split(/\r?\n/)[0];
55
+ if (first && fs.existsSync(first)) return first;
56
+ } catch {}
57
+ // 2. Fallback: common Windows install paths (multiple Python versions)
58
+ if (process.platform === "win32") {
59
+ const home = os.homedir();
60
+ for (const v of ["Python311", "Python312", "Python313", "Python310"]) {
61
+ const p = path.join(home, "AppData", "Local", "Programs", "Python", v, "Scripts", "hound.exe");
62
+ if (fs.existsSync(p)) return p;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ const INIT_TIMEOUT_MS = 15_000;
69
+ const CALL_TIMEOUT_MS = 90_000;
70
+ const CRAWL_TIMEOUT_MS = 150_000;
71
+ const INIT_ATTEMPTS = 3;
72
+ const INIT_BACKOFF_MS = 400;
73
+
74
+ // -- MCP Stdio Client (singleton subprocess + JSON-RPC) --
75
+
76
+ interface Pending {
77
+ resolve: (v: any) => void;
78
+ reject: (e: Error) => void;
79
+ timer: NodeJS.Timeout;
80
+ }
81
+
82
+ class HoundClient {
83
+ private proc: ChildProcess | null = null;
84
+ private houndExe: string | null = null;
85
+ private houndVersion: string | null = null;
86
+ private ready: Promise<boolean> | null = null;
87
+ private initInFlight: Promise<boolean> | null = null;
88
+ private nextId = 0;
89
+ private pending = new Map<number, Pending>();
90
+ private stdoutBuf = "";
91
+ private lastStderr = "";
92
+
93
+ getExe(): string | null { return this.houndExe; }
94
+
95
+ hasExe(): boolean { return this.houndExe !== null; }
96
+
97
+ async ensureReady(): Promise<boolean> {
98
+ if (this.ready) return this.ready;
99
+ // Re-resolve hound exe if not found yet (handles install-after-load)
100
+ if (!this.houndExe) {
101
+ this.houndExe = resolveHoundExe();
102
+ }
103
+ if (!this.houndExe) return false;
104
+ if (this.initInFlight) return this.initInFlight;
105
+ const p = this._initWithRetry();
106
+ this.initInFlight = p;
107
+ p.then((ok) => {
108
+ this.initInFlight = null;
109
+ if (ok) this.ready = p;
110
+ else this.ready = null;
111
+ });
112
+ return p;
113
+ }
114
+
115
+ private async _initWithRetry(): Promise<boolean> {
116
+ for (let attempt = 1; attempt <= INIT_ATTEMPTS; attempt++) {
117
+ if (await this._initOnce()) return true;
118
+ if (attempt < INIT_ATTEMPTS) await new Promise((r) => setTimeout(r, INIT_BACKOFF_MS));
119
+ }
120
+ return false;
121
+ }
122
+
123
+ private _initOnce(): Promise<boolean> {
124
+ // Kill any existing process before spawning a new one (prevents orphans)
125
+ if (this.proc && !this.proc.killed) {
126
+ try { this.proc.stdin?.end(); } catch {}
127
+ try { this.proc.kill(); } catch {}
128
+ }
129
+ this.proc = null;
130
+
131
+ return new Promise<boolean>((resolve) => {
132
+ let settled = false;
133
+ let initTimer: NodeJS.Timeout | undefined;
134
+ let stderrBuf = "";
135
+ const done = (ok: boolean) => {
136
+ if (settled) return;
137
+ settled = true;
138
+ if (initTimer) clearTimeout(initTimer);
139
+ if (!ok) this._killProcess();
140
+ resolve(ok);
141
+ };
142
+ try {
143
+ this.proc = spawn(this.houndExe!, [], {
144
+ stdio: ["pipe", "pipe", "pipe"],
145
+ windowsHide: true,
146
+ env: { ...process.env },
147
+ });
148
+ } catch (e: any) {
149
+ this.lastStderr = `spawn threw: ${e?.message ?? e}`;
150
+ done(false);
151
+ return;
152
+ }
153
+ this.proc.on("error", (e: any) => {
154
+ this.lastStderr = `spawn error: ${e?.message ?? e}`;
155
+ done(false);
156
+ });
157
+ this.proc.on("close", (code, signal) => {
158
+ for (const [, { reject, timer }] of this.pending) {
159
+ clearTimeout(timer);
160
+ reject(new Error("Hound closed"));
161
+ }
162
+ this.pending.clear();
163
+ if (!settled) this.lastStderr = `exited before init (code=${code} signal=${signal})`;
164
+ this.ready = null;
165
+ done(false);
166
+ });
167
+ this.proc.stderr?.on("data", (chunk: Buffer) => {
168
+ stderrBuf += chunk.toString("utf-8");
169
+ if (stderrBuf.length > 4000) stderrBuf = stderrBuf.slice(-4000);
170
+ });
171
+ this.proc.stdout?.on("data", (chunk: Buffer) => {
172
+ this.stdoutBuf += chunk.toString("utf-8");
173
+ this._drain();
174
+ });
175
+ initTimer = setTimeout(() => {
176
+ this.lastStderr = `initialize timed out after ${INIT_TIMEOUT_MS}ms (dead spawn)`;
177
+ done(false);
178
+ }, INIT_TIMEOUT_MS);
179
+ const id = ++this.nextId;
180
+ this.pending.set(id, {
181
+ resolve: () => {
182
+ this._notify("notifications/initialized", {});
183
+ this.lastStderr = "";
184
+ done(true);
185
+ },
186
+ reject: (e: Error) => {
187
+ this.lastStderr = `initialize rejected: ${e.message}`;
188
+ done(false);
189
+ },
190
+ timer: initTimer,
191
+ });
192
+ try {
193
+ this.proc.stdin!.write(
194
+ JSON.stringify({
195
+ jsonrpc: "2.0",
196
+ method: "initialize",
197
+ params: {
198
+ protocolVersion: "2025-03-26",
199
+ capabilities: {},
200
+ clientInfo: { name: "pi-hound", version: EXTENSION_VERSION },
201
+ },
202
+ id,
203
+ }) + "\n",
204
+ );
205
+ } catch (e: any) {
206
+ this.pending.delete(id);
207
+ this.lastStderr = `stdin write failed: ${e?.message ?? e}`;
208
+ done(false);
209
+ }
210
+ });
211
+ }
212
+
213
+ private _drain() {
214
+ let idx: number;
215
+ while ((idx = this.stdoutBuf.indexOf("\n")) !== -1) {
216
+ const line = this.stdoutBuf.slice(0, idx).trim();
217
+ this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
218
+ if (!line) continue;
219
+ try {
220
+ const msg = JSON.parse(line);
221
+ if (msg.id != null && this.pending.has(msg.id)) {
222
+ const { resolve, reject, timer } = this.pending.get(msg.id)!;
223
+ this.pending.delete(msg.id);
224
+ clearTimeout(timer);
225
+ if (msg.error) reject(new Error(msg.error?.message ?? JSON.stringify(msg.error)));
226
+ else resolve(msg.result);
227
+ }
228
+ } catch {}
229
+ }
230
+ }
231
+
232
+ private _notify(method: string, params: any) {
233
+ if (!this.proc || this.proc.killed) return;
234
+ try {
235
+ this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
236
+ } catch {}
237
+ }
238
+
239
+ async call(
240
+ name: string,
241
+ args: Record<string, any>,
242
+ timeoutMs = CALL_TIMEOUT_MS,
243
+ signal?: AbortSignal,
244
+ ): Promise<any> {
245
+ const ready = await this.ensureReady();
246
+ if (!ready) {
247
+ const hint = this.houndExe
248
+ ? `Hound failed to start after ${INIT_ATTEMPTS} attempts. ${this.lastStderr || "Silent dead hang."} Run: hound --doctor`
249
+ : "Hound not found. Install: pip install hound-mcp[all]";
250
+ throw new Error(hint);
251
+ }
252
+ return new Promise((resolve, reject) => {
253
+ if (!this.proc || this.proc.killed) {
254
+ reject(new Error("Hound not running"));
255
+ return;
256
+ }
257
+ if (signal?.aborted) {
258
+ reject(new Error(`Hound cancelled: ${name}`));
259
+ return;
260
+ }
261
+ const id = ++this.nextId;
262
+ let timer: NodeJS.Timeout;
263
+ let onAbort: () => void;
264
+ const cleanup = () => {
265
+ clearTimeout(timer);
266
+ this.pending.delete(id);
267
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
268
+ };
269
+ timer = setTimeout(() => {
270
+ cleanup();
271
+ reject(new Error(`Hound timeout: ${name}`));
272
+ }, timeoutMs);
273
+ onAbort = () => {
274
+ cleanup();
275
+ reject(new Error(`Hound cancelled: ${name}`));
276
+ };
277
+ signal?.addEventListener("abort", onAbort, { once: true });
278
+ this.pending.set(id, {
279
+ resolve: (v: any) => { cleanup(); resolve(v); },
280
+ reject: (e: Error) => { cleanup(); reject(e); },
281
+ timer,
282
+ });
283
+ try {
284
+ this.proc.stdin!.write(
285
+ JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: { name, arguments: args }, id }) + "\n",
286
+ );
287
+ } catch (e) {
288
+ cleanup();
289
+ reject(e as Error);
290
+ }
291
+ });
292
+ }
293
+
294
+ private _killProcess() {
295
+ if (this.proc && !this.proc.killed) {
296
+ try { this.proc.stdin?.end(); } catch {}
297
+ try { this.proc.kill(); } catch {}
298
+ }
299
+ this.proc = null;
300
+ this.ready = null;
301
+ this.initInFlight = null;
302
+ for (const [, { timer }] of this.pending) clearTimeout(timer);
303
+ this.pending.clear();
304
+ }
305
+
306
+ kill() { this._killProcess(); }
307
+ }
308
+
309
+ const hound = new HoundClient();
310
+
311
+ // -- Helpers --
312
+
313
+ function getText(result: any): string {
314
+ const content = result?.content ?? [];
315
+ return content.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n") || "(no output)";
316
+ }
317
+
318
+ function getImages(result: any): any[] {
319
+ const content = result?.content ?? [];
320
+ return content.filter((c: any) => c?.type === "image");
321
+ }
322
+
323
+ function tryJson(text: string): any {
324
+ try { return JSON.parse(text); } catch { return {}; }
325
+ }
326
+
327
+ function pick(params: Record<string, any>, keys: string[]): Record<string, any> {
328
+ const out: Record<string, any> = {};
329
+ for (const k of keys) {
330
+ if (params[k] !== undefined) out[k] = params[k];
331
+ }
332
+ return out;
333
+ }
334
+
335
+ // Map technical error strings from the hound server to clean, agent-friendly
336
+ // messages. The server sets result.error for all failure modes; this function
337
+ // translates it so the agent sees "Page doesn't exist (404)" instead of
338
+ // "http_error_404: server returned error status".
339
+ function cleanError(error: string, status?: number): string {
340
+ if (!error) return "";
341
+ const e = error.toLowerCase();
342
+ // HTTP status errors
343
+ if (e.includes("http_error_404") || status === 404) return "Page doesn't exist (404)";
344
+ if (e.includes("http_error_410") || status === 410) return "Page has been removed (410)";
345
+ if (e.includes("http_error_403") || status === 403) return "Access blocked by the site (403)";
346
+ if (e.includes("http_error_429") || status === 429) return "Rate limited by the site (429)";
347
+ if (e.includes("http_error_451") || status === 451) return "Page legally blocked (451)";
348
+ if (e.includes("http_error_401") || status === 401) return "Login required (401)";
349
+ if (e.includes("http_error_5") || (status && status >= 500)) return `Server error (${status})`;
350
+ if (e.includes("http_error_4") || (status && status >= 400 && status < 500)) return `Request failed (${status})`;
351
+ // Network errors
352
+ if (e.includes("network_error") || status === 0) return "Couldn't reach the site";
353
+ if (e.includes("timeout") || e.includes("timed out")) return "Request timed out";
354
+ // Content quality errors
355
+ if (e.includes("js_shell")) return "Page needs JavaScript (empty shell returned)";
356
+ if (e.includes("bot_challenge")) return "Blocked by bot protection (Cloudflare)";
357
+ if (e.includes("geo_redirect")) return "Page returned a region redirect";
358
+ if (e.includes("all_tiers_failed")) return "Couldn't fetch this page - all methods failed";
359
+ if (e.includes("auth_required")) return "Login required";
360
+ if (e.includes("robots_txt")) return "Blocked by robots.txt";
361
+ if (e.includes("encrypted_pdf")) return "PDF is password-protected";
362
+ if (e.includes("pdf_deps_missing")) return "PDF support not installed (run: pip install hound-mcp[all])";
363
+ if (e.includes("scanned_pdf")) return "Scanned PDF - needs OCR (install hound-mcp[all])";
364
+ if (e.includes("not_a_pdf")) return "File is not a PDF";
365
+ if (e.includes("pdf_open") || e.includes("pdf_extract")) return "Couldn't parse this PDF";
366
+ // Fallback: strip the technical prefix for anything unmapped
367
+ const colonIdx = error.indexOf(":");
368
+ if (colonIdx > 0) return error.slice(colonIdx + 1).trim();
369
+ return error;
370
+ }
371
+
372
+ // -- Extension --
373
+
374
+ export default function (pi: ExtensionAPI) {
375
+ pi.on("session_start", async (_event, ctx) => {
376
+ const ok = await hound.ensureReady().catch(() => false);
377
+ if (!ok) {
378
+ if (ctx.hasUI) {
379
+ ctx.ui.notify(
380
+ hound.hasExe()
381
+ ? "Hound found but failed to start. Run: hound --doctor"
382
+ : "Hound not found. Install: pip install hound-mcp[all]",
383
+ "warn",
384
+ );
385
+ }
386
+ return;
387
+ }
388
+ // Best-effort version sync check (non-blocking)
389
+ if (ctx.hasUI && EXTENSION_VERSION !== "unknown") {
390
+ hound.call("version", {}, 5_000).then((result) => {
391
+ const parsed = tryJson(getText(result));
392
+ if (parsed.version) {
393
+ hound["houndVersion"] = parsed.version;
394
+ const extMajor = EXTENSION_VERSION.split(".")[0];
395
+ const hMajor = String(parsed.version).split(".")[0];
396
+ if (extMajor !== hMajor) {
397
+ ctx.ui.notify(
398
+ `Hound extension v${EXTENSION_VERSION} vs hound v${parsed.version} (major mismatch). Update: hound -u && pi update npm:@houndmcp/hound-mcp-pi`,
399
+ "warn",
400
+ );
401
+ }
402
+ }
403
+ }).catch(() => {});
404
+ }
405
+ });
406
+
407
+ pi.on("session_shutdown", () => { hound.kill(); });
408
+
409
+ // -- web_fetch --
410
+ pi.registerTool({
411
+ name: "web_fetch",
412
+ label: "Web Fetch",
413
+ description: "Fetch a URL (or urls=[...] for parallel bulk). Auto HTTP -> stealthy escalation. Returns extracted text + metadata + signals: content_ok, next_action, summary, page_type, content_age_days/is_stale, source_type/is_official, source/archived_at. Hard-block (404/bot/auth) -> auto-recover from Internet Archive (source=archive.org, archived_at=snapshot date; archive_fallback=false in options to opt out). PDFs -> structured markdown + ToC + page ranges + auto-OCR. Long pages: paginate with offset/next_offset or focus='query' for only relevant blocks. actions=[...] for click/form/scroll. include_links/include_media via options.",
414
+ promptSnippet: "web_fetch(url|urls, extraction_type, css_selector, focus, actions, include_links, pages, offset) - anti-bot fetch + clean extraction; paginates with next_offset; dead-link recovery from Internet Archive.",
415
+ parameters: Type.Object({
416
+ url: Type.Optional(Type.String({ description: "URL to fetch" })),
417
+ urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs (parallel; returns per-URL results)" })),
418
+ extraction_type: Type.Optional(Type.String({ description: "Content format (default markdown). html = raw HTML." })),
419
+ css_selector: Type.Optional(Type.String({ description: "CSS selector to narrow extracted content (e.g. 'article', '.main'). Token saver." })),
420
+ max_content_chars: Type.Optional(Type.Integer({ description: "Max chars of extracted content (default 40000, min 500). Lower = less context; rest paginated via offset/next_offset." })),
421
+ timeout: Type.Optional(Type.Integer({ description: "Max request time in ms (default 30000)." })),
422
+ cache_ttl: Type.Optional(Type.Integer({ description: "Cache seconds (default 3600). 0 = force fresh." })),
423
+ force_fetcher: Type.Optional(Type.String({ description: "Pin to one tier, skip auto-escalation. 'http' = fast HTTP-only (fails on JS/bot walls). 'stealthy' = anti-detect browser. Default = auto." })),
424
+ offset: Type.Optional(Type.Integer({ description: "Char offset into extracted text to resume a truncated page. Use next_offset from previous response." })),
425
+ pages: Type.Optional(Type.String({ description: "PDF only: page spec like '1-5' or '1,3,5-7'. Use table_of_contents page/end_page ranges to pick. None = all pages." })),
426
+ password: Type.Optional(Type.String({ description: "PDF only: password for an encrypted PDF." })),
427
+ focus: Type.Optional(Type.String({ description: "Query-focused extraction: only BM25-relevant blocks returned. Context saver on long pages. Post-cache (no re-fetch). Re-pass same focus when paginating." })),
428
+ actions: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }), { description: "Page interactions on stealthy browser AFTER load, BEFORE extraction. Forces stealthy + bypasses cache. Each item: {click:'css'}, {fill:{selector:'css',text:'x'}}, {press:'Enter'}, {wait:500}, {scroll:3}, {wait_selector:'css'}." })),
429
+ options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "include_links (bool,false: response.links=citations/navigation/external+primary_source), include_media (bool,false: up to 20 page image URLs), archive_fallback (bool,true: recover from Internet Archive on hard-block; false=raw failure), proxy, cookies, extra_headers, useragent, wait, network_idle, headless, respect_robots, real_chrome/solve_cloudflare/block_webrtc/hide_canvas/main_content_only/use_trafilatura (anti-detect tuning, good defaults, rarely needed)." })),
430
+ }),
431
+ async execute(_id, params, signal, _onUpdate, _ctx) {
432
+ try {
433
+ const args = pick(params, ["url", "urls", "extraction_type", "css_selector", "max_content_chars", "timeout", "cache_ttl", "force_fetcher", "offset", "pages", "password", "focus", "actions", "options"]);
434
+ const result = await hound.call("mcp_smart_fetch", args, CALL_TIMEOUT_MS, signal);
435
+ const text = getText(result);
436
+ const parsed = tryJson(text);
437
+ if (Array.isArray(parsed.results)) {
438
+ const ok = parsed.successful ?? 0;
439
+ const total = parsed.total ?? parsed.results.length;
440
+ const joined = parsed.results.map((r: any) => `# ${r.url}\n${(r.content ?? []).join("\n")}`).join("\n\n---\n\n");
441
+ return { content: [{ type: "text", text: joined + `\n\n[${ok}/${total} OK]` }], details: { bulk: true, ok, total } };
442
+ }
443
+ const content = Array.isArray(parsed.content) ? parsed.content.join("\n") : text;
444
+ const foot: string[] = [];
445
+ if (parsed.summary) foot.push(parsed.summary);
446
+ if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
447
+ if (parsed.content_ok === false) foot.push("WARNING: content_ok=false - content may be a JS shell/login wall/error page");
448
+ const trunc = parsed.is_truncated ? ` | next_offset=${parsed.next_offset}` : "";
449
+ const fetcher = parsed.fetcher_used ?? "";
450
+ const src = parsed.source === "archive.org" ? ` | ARCHIVE ${parsed.archived_at ?? ""}` : "";
451
+ // When the server signals an error (http_error_*, network_error, etc.)
452
+ // AND content_ok is false, show a clean error instead of dumping the
453
+ // error page HTML as if it were real content.
454
+ if (parsed.error && parsed.content_ok === false && parsed.source !== "archive.org") {
455
+ const msg = cleanError(parsed.error, parsed.status);
456
+ return {
457
+ content: [{ type: "text", text: msg + (args.url ? `\nURL: ${args.url}` : "") + (parsed.next_action ? `\nNext: ${parsed.next_action}` : "") }],
458
+ details: { url: args.url, error: msg, content_ok: false, source: parsed.source ?? "live", status: parsed.status },
459
+ };
460
+ }
461
+ return {
462
+ content: [{ type: "text", text: content + (foot.length ? `\n\n${foot.join("\n")}` : "") + `\n[${fetcher}${trunc}${src}]` }],
463
+ details: { url: args.url, chars: content.length, content_ok: parsed.content_ok, truncated: !!parsed.is_truncated, source: parsed.source ?? "live" },
464
+ };
465
+ } catch (e: any) {
466
+ const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed")
467
+ ? e.message
468
+ : e.message.includes("timeout")
469
+ ? "Request timed out"
470
+ : e.message.includes("cancelled")
471
+ ? "Request cancelled"
472
+ : e.message.includes("Hound closed")
473
+ ? "Connection to hound lost"
474
+ : e.message;
475
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
476
+ }
477
+ },
478
+ renderCall(args, theme) {
479
+ const urlStr = (args.url ?? args.urls?.[0] ?? "").toString();
480
+ const trunc = urlStr.length > 60 ? urlStr.slice(0, 57) + "..." : urlStr;
481
+ return new Text(theme.fg("toolTitle", theme.bold("Web Fetch: ")) + theme.fg("accent", trunc), 0, 0);
482
+ },
483
+ renderResult(result, { isPartial }, theme) {
484
+ if (isPartial) return new Text(theme.fg("dim", "fetching..."), 0, 0);
485
+ const d = result.details as any;
486
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
487
+ if (d?.content_ok === false) return new Text(theme.fg("error", "failed"), 0, 0);
488
+ if (d?.bulk) return new Text(theme.fg("accent", `${d.ok}/${d.total} URLs`), 0, 0);
489
+ const kb = ((d?.chars ?? 0) / 1024).toFixed(1);
490
+ const src = d?.source === "archive.org" ? " (archive)" : "";
491
+ return new Text(theme.fg("accent", `${kb}KB${src}`), 0, 0);
492
+ },
493
+ });
494
+
495
+ // -- web_search --
496
+ pi.registerTool({
497
+ name: "web_search",
498
+ label: "Web Search",
499
+ description: "Keyless web search (no API key). 10 backends in parallel (ddg,brave,mojeek,yahoo,yandex,startpage,google,qwant + opt-in wikipedia,grokipedia), neural-reranked + cross-backend consensus. Returns URLs + ranking, NOT content -> web_fetch the ones that match. Each result: relevance_score + fetch_relevance (high/med/low) + engines_consensus. related_queries from result titles+snippets. Blocked backends circuit-broken 60s. NEVER answer from snippets alone. Filters in options: site, exclude_sites, location, language, region, page, freshness.",
500
+ promptSnippet: "web_search(query) - keyless web search across 10 backends; returns ranked URLs (not content), related_queries, consensus. web_fetch the results that match.",
501
+ parameters: Type.Object({
502
+ query: Type.String({ description: "Search query" }),
503
+ options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "max_results (1-50,6), cache_ttl (300), mode (auto|neural|find_similar), engines (list), site, exclude_sites, location, language, region, page, freshness, url (for find_similar)" })),
504
+ }),
505
+ async execute(_id, params, signal, _onUpdate, _ctx) {
506
+ try {
507
+ const args: Record<string, any> = { query: params.query };
508
+ if (params.options && Object.keys(params.options).length) args.options = params.options;
509
+ const result = await hound.call("mcp_smart_search", args, 60_000, signal);
510
+ const text = getText(result);
511
+ const parsed = tryJson(text);
512
+ const results: any[] = parsed.results ?? [];
513
+ const body = results.length === 0
514
+ ? `No results for "${params.query}".`
515
+ : results.map((r: any, i: number) => {
516
+ const cons = r.engines_consensus ? ` [consensus ${r.engines_consensus}]` : "";
517
+ const tier = r.fetch_relevance ? ` (${r.fetch_relevance})` : "";
518
+ return `[${i + 1}]${tier} ${r.title || "(untitled)"}${cons}\n ${r.url || ""}\n ${r.snippet || ""}`;
519
+ }).join("\n\n");
520
+ const foot: string[] = [];
521
+ if (Array.isArray(parsed.related_queries) && parsed.related_queries.length)
522
+ foot.push(`Related: ${parsed.related_queries.slice(0, 6).map((q: string) => `"${q}"`).join(", ")}`);
523
+ if (Array.isArray(parsed.engine_blocked) && parsed.engine_blocked.length)
524
+ foot.push(`Blocked: ${parsed.engine_blocked.join(", ")}`);
525
+ if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
526
+ return {
527
+ content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
528
+ details: { query: params.query, count: results.length, related: parsed.related_queries ?? [], blocked: parsed.engine_blocked ?? [] },
529
+ };
530
+ } catch (e: any) {
531
+ const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed") ? e.message : e.message.includes("timeout") ? "Search timed out" : e.message.includes("cancelled") ? "Search cancelled" : e.message.includes("Hound closed") ? "Connection to hound lost" : `Search failed: ${e.message}`;
532
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
533
+ }
534
+ },
535
+ renderCall(args, theme) {
536
+ const q = (args.query ?? "").toString();
537
+ const trunc = q.length > 50 ? q.slice(0, 47) + "..." : q;
538
+ return new Text(theme.fg("toolTitle", theme.bold("Web Search: ")) + theme.fg("accent", `"${trunc}"`), 0, 0);
539
+ },
540
+ renderResult(result, { isPartial }, theme) {
541
+ if (isPartial) return new Text(theme.fg("dim", "searching..."), 0, 0);
542
+ const d = result.details as any;
543
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
544
+ if (!d?.count) return new Text(theme.fg("dim", "no results"), 0, 0);
545
+ const rel = d.related?.length ? ` + ${d.related.length} related` : "";
546
+ return new Text(theme.fg("accent", `${d.count} results${rel}`), 0, 0);
547
+ },
548
+ });
549
+
550
+ // -- web_crawl --
551
+ pi.registerTool({
552
+ name: "web_crawl",
553
+ label: "Web Crawl",
554
+ description: "Deep-crawl a site: best-first same-domain walk, each page as markdown + content_ok + page_type. List pages -> structured link list. sitemap=true (in options) maps whole site from sitemap.xml in one fetch; sitemap='auto' = use if present else BFS. discover_only=true = URL map only. focus='query' prioritizes relevant pages + focus-filters each. crawl_urls=[...] fetches a chosen subset. Caps: max_pages (10), max_depth (2), max_total_chars, deadline_ms. Reuses web_fetch anti-bot + cache.",
555
+ promptSnippet: "web_crawl(url, focus, options.sitemap, discover_only, crawl_urls, max_pages) - site crawl; sitemap=true maps a whole site in one fetch; crawl_urls fetches a chosen subset.",
556
+ parameters: Type.Object({
557
+ url: Type.String({ description: "Start URL (crawl stays on this domain)" }),
558
+ focus: Type.Optional(Type.String({ description: "Query: prioritize crawling links relevant to this + focus-filter each page. Token saver on doc sites." })),
559
+ discover_only: Type.Optional(Type.Boolean({ description: "true = return URL map only, no page content. For big sites prefer options sitemap=true." })),
560
+ crawl_urls: Type.Optional(Type.Array(Type.String(), { description: "Chosen subset of URLs to fetch (second-phase selective crawl, no re-discovery). Use after sitemap=true or discover_only=true." })),
561
+ options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "sitemap (true|'auto'|false,false: true=map from sitemap.xml in one fetch; 'auto'=use if present else BFS), max_pages (1-100,10), max_depth (0-5,2), path_include (list of path prefixes), path_exclude (list to skip), max_content_chars_per (8000), max_total_chars (token budget), concurrency (1-5,3), cache_ttl (3600;0=fresh), respect_robots (false), force_fetcher ('http'|'stealthy'), timeout (ms,30000), deadline_ms (120000)." })),
562
+ }),
563
+ async execute(_id, params, signal, _onUpdate, _ctx) {
564
+ try {
565
+ const args = pick(params, ["url", "focus", "discover_only", "crawl_urls", "options"]);
566
+ const result = await hound.call("mcp_smart_crawl", args, CRAWL_TIMEOUT_MS, signal);
567
+ const text = getText(result);
568
+ const parsed = tryJson(text);
569
+ const pages: any[] = Array.isArray(parsed.pages) ? parsed.pages : [];
570
+ let body: string;
571
+ if (parsed.sitemap_used || (args.discover_only && pages.length)) {
572
+ body = pages.map((p: any, i: number) => `[${i + 1}] ${p.url}${p.lastmod ? ` (${p.lastmod})` : ""}`).join("\n");
573
+ } else {
574
+ body = pages.map((p: any) => {
575
+ const tag = p.page_type ? `[${p.page_type}]` : "";
576
+ const ok = p.content_ok ? "" : " WARNING";
577
+ const c = Array.isArray(p.content) ? p.content.join("\n") : "";
578
+ return `## ${p.url} ${tag}${ok}\n${c}`;
579
+ }).join("\n\n---\n\n");
580
+ }
581
+ const foot: string[] = [];
582
+ if (parsed.summary) foot.push(parsed.summary);
583
+ if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
584
+ return {
585
+ content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
586
+ details: { url: params.url, pages: pages.length, sitemap: !!parsed.sitemap_used },
587
+ };
588
+ } catch (e: any) {
589
+ const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed") ? e.message : e.message.includes("timeout") ? "Crawl timed out" : e.message.includes("cancelled") ? "Crawl cancelled" : e.message.includes("Hound closed") ? "Connection to hound lost" : `Crawl failed: ${e.message}`;
590
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
591
+ }
592
+ },
593
+ renderCall(args, theme) {
594
+ const urlStr = (args.url ?? "").toString();
595
+ const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
596
+ const sm = args.options?.sitemap ? " (sitemap)" : "";
597
+ return new Text(theme.fg("toolTitle", theme.bold("Web Crawl: ")) + theme.fg("accent", trunc + sm), 0, 0);
598
+ },
599
+ renderResult(result, { isPartial }, theme) {
600
+ if (isPartial) return new Text(theme.fg("dim", "crawling..."), 0, 0);
601
+ const d = result.details as any;
602
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
603
+ const sm = d.sitemap ? " (sitemap)" : "";
604
+ return new Text(theme.fg("accent", `${d.pages} pages${sm}`), 0, 0);
605
+ },
606
+ });
607
+
608
+ // -- web_screenshot --
609
+ pi.registerTool({
610
+ name: "web_screenshot",
611
+ label: "Web Screenshot",
612
+ description: "Screenshot a URL as an image. Multimodal agents only (content as images/canvas/visual layout). Text agents: use web_fetch. Stealthy browser auto-managed.",
613
+ promptSnippet: "web_screenshot(url, options.full_page, options.image_type) - anti-bot one-shot screenshot of a URL (stealthy browser). Multimodal agents only.",
614
+ parameters: Type.Object({
615
+ url: Type.String({ description: "URL to screenshot" }),
616
+ options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "full_page (bool,false), image_type (png|jpeg,png), quality (0-100,jpeg), wait (ms), wait_selector (css), network_idle (bool), timeout (ms,30000)." })),
617
+ }),
618
+ async execute(_id, params, signal, _onUpdate, _ctx) {
619
+ try {
620
+ const args: Record<string, any> = { url: params.url };
621
+ if (params.options && Object.keys(params.options).length) args.options = params.options;
622
+ const result = await hound.call("mcp_screenshot", args, CALL_TIMEOUT_MS, signal);
623
+ const images = getImages(result);
624
+ if (images.length) {
625
+ const out: any[] = [];
626
+ for (const img of images) out.push({ type: "image", data: img.data, mimeType: img.mimeType || "image/png" });
627
+ out.push({ type: "text", text: `Screenshot captured for ${params.url}` });
628
+ return { content: out, details: { url: params.url, images: images.length } };
629
+ }
630
+ const parsed = tryJson(getText(result));
631
+ return {
632
+ content: [{ type: "text", text: parsed.error ? `Capture failed: ${parsed.error}` : `Screenshot captured for ${params.url} (no image payload)` }],
633
+ details: { url: params.url, images: 0, error: parsed.error ?? "" },
634
+ };
635
+ } catch (e: any) {
636
+ const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed") ? e.message : e.message.includes("timeout") ? "Screenshot timed out" : e.message.includes("cancelled") ? "Screenshot cancelled" : e.message.includes("Hound closed") ? "Connection to hound lost" : `Screenshot failed: ${e.message}`;
637
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
638
+ }
639
+ },
640
+ renderCall(args, theme) {
641
+ const urlStr = (args.url ?? "").toString();
642
+ const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
643
+ return new Text(theme.fg("toolTitle", theme.bold("Web Screenshot: ")) + theme.fg("accent", trunc), 0, 0);
644
+ },
645
+ renderResult(result, { isPartial }, theme) {
646
+ if (isPartial) return new Text(theme.fg("dim", "capturing..."), 0, 0);
647
+ const d = result.details as any;
648
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
649
+ if (!d?.images) return new Text(theme.fg("dim", "no image"), 0, 0);
650
+ return new Text(theme.fg("accent", `${d.images} image`), 0, 0);
651
+ },
652
+ });
653
+
654
+ // -- cache_clear --
655
+ pi.registerTool({
656
+ name: "cache_clear",
657
+ label: "Clear Cache",
658
+ description: "Clear fetch cache. all=true wipes all (default: expired only). To re-fetch one URL fresh, pass cache_ttl=0 to web_fetch/web_crawl instead. Cache stores extracted text per URL+extraction_type+css_selector+pages (+ per query+filters for search); default TTL 1hr.",
659
+ promptSnippet: "cache_clear(all) - clear fetch cache. all=true wipes all (default: expired only). For one URL fresh, use cache_ttl=0 on web_fetch instead.",
660
+ parameters: Type.Object({
661
+ all: Type.Optional(Type.Boolean({ description: "Wipe all (default: expired only)" })),
662
+ }),
663
+ async execute(_id, params, signal, _onUpdate, _ctx) {
664
+ try {
665
+ const args = { all: params.all ?? false };
666
+ const result = await hound.call("cache_clear", args, 10_000, signal);
667
+ return { content: [{ type: "text", text: getText(result) }], details: { all: args.all } };
668
+ } catch (e: any) {
669
+ return { content: [{ type: "text", text: e.message.includes("Hound") ? e.message : `Cache clear failed: ${e.message}` }], details: { error: e.message } };
670
+ }
671
+ },
672
+ renderCall(_args, theme) {
673
+ return new Text(theme.fg("toolTitle", theme.bold("Clear Cache")), 0, 0);
674
+ },
675
+ renderResult(result, _meta, theme) {
676
+ const d = result.details as any;
677
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
678
+ return new Text(theme.fg("accent", "cleared"), 0, 0);
679
+ },
680
+ });
681
+
682
+ // -- hound_version --
683
+ pi.registerTool({
684
+ name: "hound_version",
685
+ label: "Hound Version",
686
+ description: "Hound version + update status.",
687
+ promptSnippet: "hound_version() - hound version + update status.",
688
+ parameters: Type.Object({}),
689
+ async execute(_id, _params, signal, _onUpdate, _ctx) {
690
+ try {
691
+ const result = await hound.call("version", {}, 10_000, signal);
692
+ const parsed = tryJson(getText(result));
693
+ const v = parsed.version || "unknown";
694
+ const latest = parsed.latest || "";
695
+ const upToDate = parsed.up_to_date;
696
+ const extInfo = EXTENSION_VERSION !== "unknown" ? ` | extension v${EXTENSION_VERSION}` : "";
697
+ const text = upToDate
698
+ ? `Hound ${v} (up to date)${extInfo}`
699
+ : `Hound ${v} (update available: ${latest})${extInfo}. Run: hound -u`;
700
+ return { content: [{ type: "text", text }], details: { version: v, latest, up_to_date: upToDate, extension: EXTENSION_VERSION } };
701
+ } catch (e: any) {
702
+ // Fallback: try hound --version via execSync (works even when MCP server is down)
703
+ try {
704
+ const exe = hound.getExe();
705
+ if (exe) {
706
+ const out = execSync(`"${exe}" --version`, { timeout: 5000, windowsHide: true }).toString().trim();
707
+ return { content: [{ type: "text", text: out }], details: { fallback: true, extension: EXTENSION_VERSION } };
708
+ }
709
+ } catch {}
710
+ return { content: [{ type: "text", text: e.message.includes("Hound") ? e.message : `Version check failed: ${e.message}` }], details: { error: e.message, extension: EXTENSION_VERSION } };
711
+ }
712
+ },
713
+ renderCall(_args, theme) {
714
+ return new Text(theme.fg("toolTitle", theme.bold("Hound Version")), 0, 0);
715
+ },
716
+ renderResult(result, _meta, theme) {
717
+ const d = result.details as any;
718
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
719
+ return new Text(theme.fg("accent", "ok"), 0, 0);
720
+ },
721
+ });
722
+ }