@kolisachint/hoocode-agent 0.5.41 → 0.5.43

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/core/builtin-skills.d.ts +11 -0
  3. package/dist/core/builtin-skills.d.ts.map +1 -1
  4. package/dist/core/builtin-skills.js +31 -0
  5. package/dist/core/builtin-skills.js.map +1 -1
  6. package/dist/core/canvas/scaffold.d.ts +1 -1
  7. package/dist/core/canvas/scaffold.d.ts.map +1 -1
  8. package/dist/core/canvas/scaffold.js +100 -5
  9. package/dist/core/canvas/scaffold.js.map +1 -1
  10. package/dist/core/settings-defaults.d.ts +1 -0
  11. package/dist/core/settings-defaults.d.ts.map +1 -1
  12. package/dist/core/settings-defaults.js +1 -0
  13. package/dist/core/settings-defaults.js.map +1 -1
  14. package/dist/core/settings-manager.d.ts +14 -0
  15. package/dist/core/settings-manager.d.ts.map +1 -1
  16. package/dist/core/settings-manager.js +15 -0
  17. package/dist/core/settings-manager.js.map +1 -1
  18. package/dist/core/settings-types.d.ts +3 -0
  19. package/dist/core/settings-types.d.ts.map +1 -1
  20. package/dist/core/settings-types.js.map +1 -1
  21. package/dist/core/tools/webtools-shared.d.ts +53 -2
  22. package/dist/core/tools/webtools-shared.d.ts.map +1 -1
  23. package/dist/core/tools/webtools-shared.js +43 -2
  24. package/dist/core/tools/webtools-shared.js.map +1 -1
  25. package/dist/extensions/core/canvas.d.ts.map +1 -1
  26. package/dist/extensions/core/canvas.js +2 -1
  27. package/dist/extensions/core/canvas.js.map +1 -1
  28. package/dist/init-templates.generated.d.ts.map +1 -1
  29. package/dist/init-templates.generated.js +4 -0
  30. package/dist/init-templates.generated.js.map +1 -1
  31. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  32. package/dist/modes/interactive/components/settings-selector.js +11 -0
  33. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  34. package/dist/modes/interactive/interactive-mode.d.ts +6 -0
  35. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  36. package/dist/modes/interactive/interactive-mode.js +16 -0
  37. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  38. package/dist/modes/interactive/websearch-warning.d.ts +35 -0
  39. package/dist/modes/interactive/websearch-warning.d.ts.map +1 -0
  40. package/dist/modes/interactive/websearch-warning.js +40 -0
  41. package/dist/modes/interactive/websearch-warning.js.map +1 -0
  42. package/docs/settings.md +52 -2
  43. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  44. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  45. package/examples/extensions/sandbox/package.json +1 -1
  46. package/examples/extensions/with-deps/package.json +1 -1
  47. package/package.json +4 -4
  48. package/templates/agents/code-review.md +61 -0
  49. package/templates/agents/security-review.md +69 -0
  50. package/templates/skills/artifact-design/SKILL.md +191 -0
  51. package/templates/skills/canvas-design/SKILL.md +117 -0
@@ -6,9 +6,10 @@
6
6
  * This module owns:
7
7
  * - the spawn-and-parse runner,
8
8
  * - the locked JSON result types,
9
- * - a short-lived in-process result cache, and
9
+ * - a short-lived in-process result cache,
10
10
  * - the `.webtoolsignore` policy matcher (gitignore semantics) used to block
11
- * hosts both before a fetch and when filtering search result links.
11
+ * hosts both before a fetch and when filtering search result links, and
12
+ * - the read-only check for whether `websearch` has a keyed backend configured.
12
13
  */
13
14
  import ignore from "ignore";
14
15
  type IgnoreMatcher = ReturnType<typeof ignore>;
@@ -98,6 +99,56 @@ export declare function resolveWebtoolsTLSConfig(overrides?: WebtoolsTLSConfig):
98
99
  * clamped to [1, 120].
99
100
  */
100
101
  export declare function resolveWebtoolsTimeoutSecs(override?: number): number;
102
+ /**
103
+ * The `webtools.search` block of `~/.hoocode/settings.json`.
104
+ *
105
+ * hoocode and the binary share that file: the binary reads its own `webtools`
106
+ * key (snake_case, per its own schema) and ignores everything else, so these
107
+ * keys are mirrored verbatim rather than camelCased. hoocode never writes them
108
+ * — it only reads them to tell whether `websearch` has a keyed backend.
109
+ */
110
+ export interface WebtoolsSearchSettings {
111
+ /** Primary backend: "duckduckgo" | "brave" | "tavily" | "searxng". */
112
+ provider?: string;
113
+ /** Backend tried when the primary fails; "none" disables the fallback. */
114
+ fallback?: string;
115
+ providers?: {
116
+ brave?: {
117
+ api_key?: string;
118
+ };
119
+ tavily?: {
120
+ api_key?: string;
121
+ };
122
+ searxng?: {
123
+ base_url?: string;
124
+ api_key?: string;
125
+ };
126
+ };
127
+ }
128
+ /** A search backend that answers over an API contract instead of scraped HTML. */
129
+ export type KeyedSearchProvider = "brave" | "tavily" | "searxng";
130
+ export interface WebSearchCredentialStatus {
131
+ /** A keyed backend is reachable, so search does not depend on scraped DuckDuckGo. */
132
+ configured: boolean;
133
+ /** Which backend the credential belongs to, when one is configured. */
134
+ provider?: KeyedSearchProvider;
135
+ /** Where the credential came from — env wins over the settings file. */
136
+ source?: "env" | "settings";
137
+ /** The user explicitly asked for the keyless backend, so nothing is missing. */
138
+ explicitKeyless?: boolean;
139
+ }
140
+ /**
141
+ * Whether `websearch` has a keyed backend configured, and where it came from.
142
+ *
143
+ * Mirrors the binary's own resolution order (env over settings file) for the
144
+ * three keyed backends. This is a read-only check used to decide whether to
145
+ * tell the user that search is running on keyless DuckDuckGo — it never
146
+ * returns the credential itself, so a key cannot leak into the UI or a log.
147
+ *
148
+ * A provider pinned to `duckduckgo` (env or settings) is reported as
149
+ * `explicitKeyless`: the user chose the scraped backend, so nothing is missing.
150
+ */
151
+ export declare function resolveWebSearchCredentials(search?: WebtoolsSearchSettings): WebSearchCredentialStatus;
101
152
  /**
102
153
  * Run a `webtools` subcommand with `--json` and return parsed stdout.
103
154
  *
@@ -1 +1 @@
1
- {"version":3,"file":"webtools-shared.d.ts","sourceRoot":"","sources":["../../../src/core/tools/webtools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `webfetch` and `websearch` tools.\n *\n * Both tools shell out to the `webtools` binary (fetch / search subcommands,\n * resolved/downloaded via {@link ensureTool}) and parse its `--json` output.\n * This module owns:\n * - the spawn-and-parse runner,\n * - the locked JSON result types,\n * - a short-lived in-process result cache, and\n * - the `.webtoolsignore` policy matcher (gitignore semantics) used to block\n * hosts both before a fetch and when filtering search result links.\n */\n\nimport { accessSync, constants, existsSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport chalk from \"chalk\";\nimport ignore from \"ignore\";\nimport { getAgentDir } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\n/** Default request timeout (seconds) passed to the binary. */\nconst WEBTOOLS_DEFAULT_TIMEOUT_SECS = 15;\n\n/** Lower/upper bounds on the effective request timeout (seconds). */\nconst WEBTOOLS_MIN_TIMEOUT_SECS = 1;\nconst WEBTOOLS_MAX_TIMEOUT_SECS = 120;\n\n/** How long a successful result stays cached, mirroring the documented 15-min TTL. */\nconst CACHE_TTL_MS = 15 * 60 * 1000;\n\n// ============================================================================\n// Result types (locked against `webtools <cmd> --json`)\n// ============================================================================\n\n/**\n * Whether the binary actually extracted content (`FetchResult.status`).\n *\n * Optional here because an older `webtools` on PATH predates the field; absent\n * is treated as `ok`. Without this, a JavaScript-rendered shell and a genuinely\n * blank page are both \"empty content, exit 0\" and the model reads either as\n * \"this page has nothing to say\".\n */\nexport type WebFetchContentStatus = \"ok\" | \"empty\" | \"needs_js\" | \"too_complex\";\n\n/** Whether the search answered (`SearchOutput.status`). See {@link WebFetchContentStatus} on optionality. */\nexport type WebSearchStatus = \"ok\" | \"empty\" | \"blocked\";\n\n/** One-line explanation for a non-`ok` fetch status, mirroring the binary's own note. */\nexport function fetchStatusNote(status: WebFetchContentStatus | undefined): string | undefined {\n\tswitch (status) {\n\t\tcase \"empty\":\n\t\t\treturn \"the page parsed but contains no text\";\n\t\tcase \"needs_js\":\n\t\t\treturn \"no text content: the page renders its body with JavaScript, which webtools does not execute\";\n\t\tcase \"too_complex\":\n\t\t\treturn \"the document is too deeply nested to parse safely and was refused\";\n\t\tdefault:\n\t\t\treturn undefined;\n\t}\n}\n\ninterface WebFetchReference {\n\tindex: number;\n\turl: string;\n\ttext?: string;\n}\n\ninterface WebFetchMetadata {\n\tdescription?: string;\n\tauthor?: string;\n\tpublished?: string;\n\tlang?: string;\n\tsite_name?: string;\n}\n\nexport interface WebFetchResult {\n\ttitle?: string;\n\tfinal_url: string;\n\tcontent: string;\n\tcontent_type: string;\n\tmedia: string;\n\ttoken_estimate: number;\n\t/** Absent on binaries older than the status field; treated as \"ok\". */\n\tstatus?: WebFetchContentStatus;\n\treferences: WebFetchReference[];\n\tmetadata?: WebFetchMetadata;\n\t/** The URL that was requested, before any redirect (`final_url` is post-redirect). */\n\tsource: string;\n}\n\nexport interface WebSearchResultItem {\n\ttitle: string;\n\tsnippet: string;\n\turl: string;\n\tref_index: number;\n}\n\ninterface WebSearchReference {\n\tindex: number;\n\turl: string;\n}\n\nexport interface WebSearchOutput {\n\tquery: string;\n\tresults: WebSearchResultItem[];\n\treferences: WebSearchReference[];\n\ttoken_estimate: number;\n\tresult_count: number;\n\t/** Absent on binaries older than the status field; treated as \"ok\". */\n\tstatus?: WebSearchStatus;\n\t/** Which backend answered, so a silent fallback to DuckDuckGo stays visible. */\n\tprovider?: string;\n}\n\n// ============================================================================\n// Binary runner\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"webtools binary unavailable and could not be downloaded — web tools require the `webtools` CLI on PATH or a published release for this platform\";\n\n/**\n * TLS plumbing forwarded to the `webtools` binary for `webfetch`/`websearch`.\n * Kept separate from hoocode's own app-level TLS trust (utils/tls-ca.ts): the\n * binary has its own TLS stack, so it needs the CA / insecure flag passed in.\n */\nexport interface WebtoolsTLSConfig {\n\t/** Path to a PEM CA bundle forwarded as `--ca-cert <path>` (validated readable). */\n\tcaCertPath?: string;\n\t/** Forward `--insecure` (disables TLS verification in the binary). Strictly opt-in. */\n\tinsecure?: boolean;\n}\n\nfunction isTruthyEnv(value: string | undefined): boolean {\n\tif (!value) return false;\n\tconst normalized = value.trim().toLowerCase();\n\treturn normalized === \"1\" || normalized === \"true\" || normalized === \"yes\";\n}\n\n/**\n * Resolve the webtools TLS config from explicit overrides (e.g. settings.json\n * passed down from the tool factories) falling back to the environment\n * (`HOOCODE_WEBTOOLS_CA_CERT`, `HOOCODE_WEBTOOLS_INSECURE`). Never hardcoded.\n */\nexport function resolveWebtoolsTLSConfig(overrides?: WebtoolsTLSConfig): WebtoolsTLSConfig {\n\tconst envCaCert = process.env.HOOCODE_WEBTOOLS_CA_CERT?.trim();\n\tconst caCertPath = overrides?.caCertPath ?? (envCaCert && envCaCert.length > 0 ? envCaCert : undefined);\n\tconst insecure = overrides?.insecure ?? isTruthyEnv(process.env.HOOCODE_WEBTOOLS_INSECURE);\n\treturn { caCertPath, insecure };\n}\n\n/** Clamp a request timeout to the supported range, flooring to whole seconds. */\nfunction clampTimeoutSecs(secs: number): number {\n\treturn Math.min(WEBTOOLS_MAX_TIMEOUT_SECS, Math.max(WEBTOOLS_MIN_TIMEOUT_SECS, Math.floor(secs)));\n}\n\n/**\n * Resolve the effective webtools request timeout (seconds) from an explicit\n * override (e.g. settings.json passed down from the tool factories) falling back\n * to the environment (`HOOCODE_WEBTOOLS_TIMEOUT`) and finally the default. Mirrors\n * {@link resolveWebtoolsTLSConfig}: resolve once, thread in, never hardcode. A\n * malformed or out-of-range env value falls back to the default; every result is\n * clamped to [1, 120].\n */\nexport function resolveWebtoolsTimeoutSecs(override?: number): number {\n\tif (override !== undefined && Number.isFinite(override)) {\n\t\treturn clampTimeoutSecs(override);\n\t}\n\tconst envRaw = process.env.HOOCODE_WEBTOOLS_TIMEOUT?.trim();\n\tif (envRaw) {\n\t\tconst envValue = Number(envRaw);\n\t\tif (Number.isFinite(envValue) && envValue > 0) {\n\t\t\treturn clampTimeoutSecs(envValue);\n\t\t}\n\t}\n\treturn WEBTOOLS_DEFAULT_TIMEOUT_SECS;\n}\n\n// Warn at most once per distinct message for the life of the process.\nconst warnedWebtoolsMessages = new Set<string>();\nfunction warnOnce(message: string): void {\n\tif (warnedWebtoolsMessages.has(message)) return;\n\twarnedWebtoolsMessages.add(message);\n\tconsole.warn(chalk.yellow(`[webtools] ${message}`));\n}\n\n/** True only when `path` is a readable regular file; warns once and returns false otherwise. */\nfunction isReadableFile(path: string): boolean {\n\ttry {\n\t\tif (!statSync(path).isFile()) {\n\t\t\twarnOnce(`--ca-cert path is not a regular file, ignoring: ${path}`);\n\t\t\treturn false;\n\t\t}\n\t\taccessSync(path, constants.R_OK);\n\t\treturn true;\n\t} catch (error) {\n\t\tconst reason = error instanceof Error ? error.message : String(error);\n\t\twarnOnce(`--ca-cert path is not readable, ignoring: ${path} (${reason})`);\n\t\treturn false;\n\t}\n}\n\n/** Build the TLS-related argv flags forwarded to the binary (argv array, no shell). */\nfunction buildTLSArgs(config: WebtoolsTLSConfig | undefined): string[] {\n\tconst flags: string[] = [];\n\tif (!config) return flags;\n\tif (config.caCertPath && isReadableFile(config.caCertPath)) {\n\t\tflags.push(\"--ca-cert\", config.caCertPath);\n\t}\n\tif (config.insecure) {\n\t\twarnOnce(\n\t\t\t\"webtools running with --insecure: TLS verification is DISABLED for webfetch/websearch. \" +\n\t\t\t\t\"Prefer HOOCODE_WEBTOOLS_CA_CERT to trust your proxy's CA with verification kept on.\",\n\t\t);\n\t\tflags.push(\"--insecure\");\n\t}\n\treturn flags;\n}\n\n/**\n * The binary bounds a whole fetch (redirects + retries) at this multiple of the\n * per-request `--timeout`, so the spawn must outlive that or we kill a fetch the\n * binary would have finished. Search has no such budget, but it may try a\n * fallback provider after the primary fails, so it gets two requests' worth.\n */\nconst WHOLE_RUN_TIMEOUT_MULTIPLIER: Record<\"fetch\" | \"search\", number> = {\n\tfetch: 3,\n\tsearch: 2,\n};\n\n/** Extra wall-clock headroom (seconds) so the binary reports its own timeout before we kill it. */\nconst SPAWN_TIMEOUT_HEADROOM_SECS = 5;\n\n/**\n * Turn a non-zero exit into the most specific message available. `search` exits\n * non-zero on a blocked provider but writes its JSON (carrying `status`) to\n * stdout with nothing on stderr, so the generic \"exited with code 1\" would throw\n * away the only useful detail.\n */\nfunction describeFailedRun(subcommand: \"fetch\" | \"search\", stdout: string, stderr: string, code: number): string {\n\tconst trimmedStderr = stderr.trim();\n\tif (trimmedStderr) return trimmedStderr;\n\n\ttry {\n\t\tconst parsed = JSON.parse(stdout) as { status?: string };\n\t\tif (parsed?.status === \"blocked\") {\n\t\t\treturn (\n\t\t\t\t\"web search was blocked by the provider (bot challenge or rate limit) rather than returning no results — \" +\n\t\t\t\t\"retry later, or configure a different search provider\"\n\t\t\t);\n\t\t}\n\t} catch {\n\t\t// Not JSON: fall through to the generic message.\n\t}\n\treturn `webtools ${subcommand} exited with code ${code}`;\n}\n\n/**\n * Run a `webtools` subcommand with `--json` and return parsed stdout.\n *\n * Throws on missing binary, non-zero exit (surfacing the binary's stderr, or the\n * status carried on stdout when stderr is empty), or unparseable output. Callers\n * convert thrown errors into tool error results.\n */\nexport async function runWebtools<T>(\n\tsubcommand: \"fetch\" | \"search\",\n\targs: string[],\n\tcwd: string,\n\tsignal?: AbortSignal,\n\ttimeoutSecs: number = WEBTOOLS_DEFAULT_TIMEOUT_SECS,\n\ttlsConfig?: WebtoolsTLSConfig,\n): Promise<T> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\tconst binaryPath = await ensureTool(\"webtools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\n\t// Give the spawn headroom over the binary's own worst-case runtime so the\n\t// binary reports the timeout itself rather than being killed mid-flight.\n\tconst wholeRunSecs = timeoutSecs * WHOLE_RUN_TIMEOUT_MULTIPLIER[subcommand];\n\tconst spawnTimeoutMs = (wholeRunSecs + SPAWN_TIMEOUT_HEADROOM_SECS) * 1000;\n\tconst tlsArgs = buildTLSArgs(tlsConfig);\n\t// `--timeout` must be forwarded: without it the binary falls back to its own\n\t// default and the resolved setting/env value would never reach the request.\n\tconst result = await execCommand(\n\t\tbinaryPath,\n\t\t[subcommand, ...args, \"--timeout\", String(timeoutSecs), ...tlsArgs, \"--json\"],\n\t\tcwd,\n\t\t{\n\t\t\tsignal,\n\t\t\ttimeout: spawnTimeoutMs,\n\t\t},\n\t);\n\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`webtools ${subcommand} timed out after ${wholeRunSecs}s`);\n\tif (result.code !== 0) {\n\t\tthrow new Error(describeFailedRun(subcommand, result.stdout, result.stderr, result.code));\n\t}\n\n\ttry {\n\t\treturn JSON.parse(result.stdout) as T;\n\t} catch {\n\t\tthrow new Error(`webtools ${subcommand} returned malformed JSON`);\n\t}\n}\n\n// ============================================================================\n// Result cache (per-process, short TTL)\n// ============================================================================\n\ninterface CacheEntry<T> {\n\tvalue: T;\n\texpiresAt: number;\n}\n\n/**\n * A computation shared by every caller that requested the same key while it was\n * still running. The subprocess is only aborted once *all* joined callers have\n * aborted, tracked by {@link refCount} against a shared {@link controller}.\n */\ninterface InFlightEntry<T> {\n\tpromise: Promise<T>;\n\tcontroller: AbortController;\n\trefCount: number;\n}\n\nexport class WebToolsCache<T> {\n\tprivate readonly entries = new Map<string, CacheEntry<T>>();\n\tprivate readonly inflight = new Map<string, InFlightEntry<T>>();\n\n\tget(key: string): T | undefined {\n\t\tconst entry = this.entries.get(key);\n\t\tif (!entry) return undefined;\n\t\tif (Date.now() >= entry.expiresAt) {\n\t\t\tthis.entries.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: T): void {\n\t\tthis.entries.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });\n\t}\n\n\t/**\n\t * Return a cached value, join an identical in-flight computation, or start a\n\t * new one — collapsing concurrent duplicate fetch/search calls onto a single\n\t * subprocess. Successful results are cached; failures are not.\n\t *\n\t * Cancellation is shared safely: a caller whose own `signal` aborts rejects\n\t * promptly and releases its reference, but the underlying work keeps running\n\t * for the remaining callers and is only cancelled once none are left.\n\t */\n\tasync getOrCompute(\n\t\tkey: string,\n\t\tsignal: AbortSignal | undefined,\n\t\tcompute: (signal: AbortSignal) => Promise<T>,\n\t): Promise<T> {\n\t\tconst cached = this.get(key);\n\t\tif (cached !== undefined) return cached;\n\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\tlet entry = this.inflight.get(key);\n\t\tif (!entry) {\n\t\t\tconst controller = new AbortController();\n\t\t\tconst promise = (async () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst value = await compute(controller.signal);\n\t\t\t\t\tthis.set(key, value);\n\t\t\t\t\treturn value;\n\t\t\t\t} finally {\n\t\t\t\t\tthis.inflight.delete(key);\n\t\t\t\t}\n\t\t\t})();\n\t\t\tentry = { promise, controller, refCount: 0 };\n\t\t\tthis.inflight.set(key, entry);\n\t\t}\n\n\t\tconst joined = entry;\n\t\t// Every joined caller (signalled or not) holds a reference; the shared work\n\t\t// is cancelled only when an abort drops the count back to zero.\n\t\tjoined.refCount++;\n\n\t\tif (!signal) {\n\t\t\treturn joined.promise;\n\t\t}\n\n\t\tconst onAbort = () => {\n\t\t\tif (joined.refCount > 0) joined.refCount--;\n\t\t\tif (joined.refCount === 0) joined.controller.abort();\n\t\t};\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t\ttry {\n\t\t\treturn await Promise.race([\n\t\t\t\tjoined.promise,\n\t\t\t\tnew Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Operation aborted\")), { once: true });\n\t\t\t\t}),\n\t\t\t]);\n\t\t} finally {\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t}\n\t}\n}\n\n// ============================================================================\n// .webtoolsignore policy matcher\n// ============================================================================\n\n/**\n * Memoize the parsed matcher per cwd. The policy is consulted on every webfetch\n * (twice: permission gate + tool execute) and every websearch, so re-reading and\n * re-parsing three files each time is wasted sync I/O on the hot path. The cache\n * is invalidated by a cheap stat signature (existence + mtime + size) so an\n * edited `.webtoolsignore` still takes effect immediately — correctness matters\n * here because this gate enforces host policy.\n */\ninterface IgnoreCacheEntry {\n\tsignature: string;\n\tmatcher: IgnoreMatcher | undefined;\n}\nconst ignoreCacheByCwd = new Map<string, IgnoreCacheEntry>();\n\nfunction ignoreSignature(files: string[]): string {\n\treturn files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\tconst st = statSync(file);\n\t\t\t\treturn `${file}:${st.mtimeMs}:${st.size}`;\n\t\t\t} catch {\n\t\t\t\treturn `${file}:absent`;\n\t\t\t}\n\t\t})\n\t\t.join(\"|\");\n}\n\n/**\n * Build an {@link Ignore} matcher from `.webtoolsignore` policy files.\n *\n * Precedence is project-after-user so a project file can re-allow (`!host`)\n * something the user blocked, matching gitignore layering. Returns undefined\n * when no policy files exist (the common case: everything allowed).\n *\n * Hosts are matched as single path components, so subdomains need an explicit\n * wildcard (`*.example.com`), exactly like gitignore directory matching.\n */\nexport function loadWebtoolsIgnore(cwd: string): IgnoreMatcher | undefined {\n\tconst files = [\n\t\tjoin(getAgentDir(), \"webtoolsignore\"),\n\t\tjoin(homedir(), \".webtoolsignore\"),\n\t\tjoin(cwd, \".webtoolsignore\"),\n\t];\n\n\tconst signature = ignoreSignature(files);\n\tconst cached = ignoreCacheByCwd.get(cwd);\n\tif (cached && cached.signature === signature) {\n\t\treturn cached.matcher;\n\t}\n\n\tlet found = false;\n\tconst ig = ignore();\n\tfor (const file of files) {\n\t\tif (!existsSync(file)) continue;\n\t\ttry {\n\t\t\tig.add(readFileSync(file, \"utf8\"));\n\t\t\tfound = true;\n\t\t} catch {\n\t\t\t// Unreadable policy file: ignore it rather than failing the tool call.\n\t\t}\n\t}\n\tconst matcher = found ? ig : undefined;\n\tignoreCacheByCwd.set(cwd, { signature, matcher });\n\treturn matcher;\n}\n\n/** Extract the lowercased hostname from a URL, or undefined if it cannot be parsed. */\nexport function hostnameOf(url: string): string | undefined {\n\ttry {\n\t\tconst host = new URL(url).hostname.toLowerCase();\n\t\treturn host || undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Whether a host is blocked by policy. A matcher is required; with no policy\n * files present callers treat every host as allowed.\n */\nexport function isHostBlocked(matcher: IgnoreMatcher, host: string): boolean {\n\tif (!host) return false;\n\treturn matcher.ignores(host);\n}\n\n/**\n * Convenience used by the permission gate: returns the blocked host for a URL,\n * or undefined when the URL is allowed (or there is no policy / unparseable URL).\n */\nexport function blockedHostForUrl(cwd: string, url: string): string | undefined {\n\tconst matcher = loadWebtoolsIgnore(cwd);\n\tif (!matcher) return undefined;\n\tconst host = hostnameOf(url);\n\tif (!host) return undefined;\n\treturn isHostBlocked(matcher, host) ? host : undefined;\n}\n"]}
1
+ {"version":3,"file":"webtools-shared.d.ts","sourceRoot":"","sources":["../../../src/core/tools/webtools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `webfetch` and `websearch` tools.\n *\n * Both tools shell out to the `webtools` binary (fetch / search subcommands,\n * resolved/downloaded via {@link ensureTool}) and parse its `--json` output.\n * This module owns:\n * - the spawn-and-parse runner,\n * - the locked JSON result types,\n * - a short-lived in-process result cache,\n * - the `.webtoolsignore` policy matcher (gitignore semantics) used to block\n * hosts both before a fetch and when filtering search result links, and\n * - the read-only check for whether `websearch` has a keyed backend configured.\n */\n\nimport { accessSync, constants, existsSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport chalk from \"chalk\";\nimport ignore from \"ignore\";\nimport { getAgentDir } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\n/** Default request timeout (seconds) passed to the binary. */\nconst WEBTOOLS_DEFAULT_TIMEOUT_SECS = 15;\n\n/** Lower/upper bounds on the effective request timeout (seconds). */\nconst WEBTOOLS_MIN_TIMEOUT_SECS = 1;\nconst WEBTOOLS_MAX_TIMEOUT_SECS = 120;\n\n/** How long a successful result stays cached, mirroring the documented 15-min TTL. */\nconst CACHE_TTL_MS = 15 * 60 * 1000;\n\n// ============================================================================\n// Result types (locked against `webtools <cmd> --json`)\n// ============================================================================\n\n/**\n * Whether the binary actually extracted content (`FetchResult.status`).\n *\n * Optional here because an older `webtools` on PATH predates the field; absent\n * is treated as `ok`. Without this, a JavaScript-rendered shell and a genuinely\n * blank page are both \"empty content, exit 0\" and the model reads either as\n * \"this page has nothing to say\".\n */\nexport type WebFetchContentStatus = \"ok\" | \"empty\" | \"needs_js\" | \"too_complex\";\n\n/** Whether the search answered (`SearchOutput.status`). See {@link WebFetchContentStatus} on optionality. */\nexport type WebSearchStatus = \"ok\" | \"empty\" | \"blocked\";\n\n/** One-line explanation for a non-`ok` fetch status, mirroring the binary's own note. */\nexport function fetchStatusNote(status: WebFetchContentStatus | undefined): string | undefined {\n\tswitch (status) {\n\t\tcase \"empty\":\n\t\t\treturn \"the page parsed but contains no text\";\n\t\tcase \"needs_js\":\n\t\t\treturn \"no text content: the page renders its body with JavaScript, which webtools does not execute\";\n\t\tcase \"too_complex\":\n\t\t\treturn \"the document is too deeply nested to parse safely and was refused\";\n\t\tdefault:\n\t\t\treturn undefined;\n\t}\n}\n\ninterface WebFetchReference {\n\tindex: number;\n\turl: string;\n\ttext?: string;\n}\n\ninterface WebFetchMetadata {\n\tdescription?: string;\n\tauthor?: string;\n\tpublished?: string;\n\tlang?: string;\n\tsite_name?: string;\n}\n\nexport interface WebFetchResult {\n\ttitle?: string;\n\tfinal_url: string;\n\tcontent: string;\n\tcontent_type: string;\n\tmedia: string;\n\ttoken_estimate: number;\n\t/** Absent on binaries older than the status field; treated as \"ok\". */\n\tstatus?: WebFetchContentStatus;\n\treferences: WebFetchReference[];\n\tmetadata?: WebFetchMetadata;\n\t/** The URL that was requested, before any redirect (`final_url` is post-redirect). */\n\tsource: string;\n}\n\nexport interface WebSearchResultItem {\n\ttitle: string;\n\tsnippet: string;\n\turl: string;\n\tref_index: number;\n}\n\ninterface WebSearchReference {\n\tindex: number;\n\turl: string;\n}\n\nexport interface WebSearchOutput {\n\tquery: string;\n\tresults: WebSearchResultItem[];\n\treferences: WebSearchReference[];\n\ttoken_estimate: number;\n\tresult_count: number;\n\t/** Absent on binaries older than the status field; treated as \"ok\". */\n\tstatus?: WebSearchStatus;\n\t/** Which backend answered, so a silent fallback to DuckDuckGo stays visible. */\n\tprovider?: string;\n}\n\n// ============================================================================\n// Binary runner\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"webtools binary unavailable and could not be downloaded — web tools require the `webtools` CLI on PATH or a published release for this platform\";\n\n/**\n * TLS plumbing forwarded to the `webtools` binary for `webfetch`/`websearch`.\n * Kept separate from hoocode's own app-level TLS trust (utils/tls-ca.ts): the\n * binary has its own TLS stack, so it needs the CA / insecure flag passed in.\n */\nexport interface WebtoolsTLSConfig {\n\t/** Path to a PEM CA bundle forwarded as `--ca-cert <path>` (validated readable). */\n\tcaCertPath?: string;\n\t/** Forward `--insecure` (disables TLS verification in the binary). Strictly opt-in. */\n\tinsecure?: boolean;\n}\n\nfunction isTruthyEnv(value: string | undefined): boolean {\n\tif (!value) return false;\n\tconst normalized = value.trim().toLowerCase();\n\treturn normalized === \"1\" || normalized === \"true\" || normalized === \"yes\";\n}\n\n/**\n * Resolve the webtools TLS config from explicit overrides (e.g. settings.json\n * passed down from the tool factories) falling back to the environment\n * (`HOOCODE_WEBTOOLS_CA_CERT`, `HOOCODE_WEBTOOLS_INSECURE`). Never hardcoded.\n */\nexport function resolveWebtoolsTLSConfig(overrides?: WebtoolsTLSConfig): WebtoolsTLSConfig {\n\tconst envCaCert = process.env.HOOCODE_WEBTOOLS_CA_CERT?.trim();\n\tconst caCertPath = overrides?.caCertPath ?? (envCaCert && envCaCert.length > 0 ? envCaCert : undefined);\n\tconst insecure = overrides?.insecure ?? isTruthyEnv(process.env.HOOCODE_WEBTOOLS_INSECURE);\n\treturn { caCertPath, insecure };\n}\n\n/** Clamp a request timeout to the supported range, flooring to whole seconds. */\nfunction clampTimeoutSecs(secs: number): number {\n\treturn Math.min(WEBTOOLS_MAX_TIMEOUT_SECS, Math.max(WEBTOOLS_MIN_TIMEOUT_SECS, Math.floor(secs)));\n}\n\n/**\n * Resolve the effective webtools request timeout (seconds) from an explicit\n * override (e.g. settings.json passed down from the tool factories) falling back\n * to the environment (`HOOCODE_WEBTOOLS_TIMEOUT`) and finally the default. Mirrors\n * {@link resolveWebtoolsTLSConfig}: resolve once, thread in, never hardcode. A\n * malformed or out-of-range env value falls back to the default; every result is\n * clamped to [1, 120].\n */\nexport function resolveWebtoolsTimeoutSecs(override?: number): number {\n\tif (override !== undefined && Number.isFinite(override)) {\n\t\treturn clampTimeoutSecs(override);\n\t}\n\tconst envRaw = process.env.HOOCODE_WEBTOOLS_TIMEOUT?.trim();\n\tif (envRaw) {\n\t\tconst envValue = Number(envRaw);\n\t\tif (Number.isFinite(envValue) && envValue > 0) {\n\t\t\treturn clampTimeoutSecs(envValue);\n\t\t}\n\t}\n\treturn WEBTOOLS_DEFAULT_TIMEOUT_SECS;\n}\n\n// ============================================================================\n// Search provider credentials\n// ============================================================================\n\n/**\n * The `webtools.search` block of `~/.hoocode/settings.json`.\n *\n * hoocode and the binary share that file: the binary reads its own `webtools`\n * key (snake_case, per its own schema) and ignores everything else, so these\n * keys are mirrored verbatim rather than camelCased. hoocode never writes them\n * — it only reads them to tell whether `websearch` has a keyed backend.\n */\nexport interface WebtoolsSearchSettings {\n\t/** Primary backend: \"duckduckgo\" | \"brave\" | \"tavily\" | \"searxng\". */\n\tprovider?: string;\n\t/** Backend tried when the primary fails; \"none\" disables the fallback. */\n\tfallback?: string;\n\tproviders?: {\n\t\tbrave?: { api_key?: string };\n\t\ttavily?: { api_key?: string };\n\t\tsearxng?: { base_url?: string; api_key?: string };\n\t};\n}\n\n/** A search backend that answers over an API contract instead of scraped HTML. */\nexport type KeyedSearchProvider = \"brave\" | \"tavily\" | \"searxng\";\n\nexport interface WebSearchCredentialStatus {\n\t/** A keyed backend is reachable, so search does not depend on scraped DuckDuckGo. */\n\tconfigured: boolean;\n\t/** Which backend the credential belongs to, when one is configured. */\n\tprovider?: KeyedSearchProvider;\n\t/** Where the credential came from — env wins over the settings file. */\n\tsource?: \"env\" | \"settings\";\n\t/** The user explicitly asked for the keyless backend, so nothing is missing. */\n\texplicitKeyless?: boolean;\n}\n\n/** Env var names per keyed provider, in the precedence the binary applies. */\nconst SEARCH_CREDENTIAL_ENV: ReadonlyArray<{ provider: KeyedSearchProvider; vars: readonly string[] }> = [\n\t{ provider: \"brave\", vars: [\"WEBTOOLS_BRAVE_API_KEY\", \"BRAVE_API_KEY\"] },\n\t{ provider: \"tavily\", vars: [\"WEBTOOLS_TAVILY_API_KEY\", \"TAVILY_API_KEY\"] },\n\t// SearXNG is self-hosted: the endpoint is the credential, its key optional.\n\t{ provider: \"searxng\", vars: [\"WEBTOOLS_SEARXNG_URL\"] },\n];\n\nfunction hasText(value: string | undefined): boolean {\n\treturn typeof value === \"string\" && value.trim().length > 0;\n}\n\n/**\n * Whether `websearch` has a keyed backend configured, and where it came from.\n *\n * Mirrors the binary's own resolution order (env over settings file) for the\n * three keyed backends. This is a read-only check used to decide whether to\n * tell the user that search is running on keyless DuckDuckGo — it never\n * returns the credential itself, so a key cannot leak into the UI or a log.\n *\n * A provider pinned to `duckduckgo` (env or settings) is reported as\n * `explicitKeyless`: the user chose the scraped backend, so nothing is missing.\n */\nexport function resolveWebSearchCredentials(search?: WebtoolsSearchSettings): WebSearchCredentialStatus {\n\tconst pinned = (process.env.WEBTOOLS_SEARCH_PROVIDER ?? search?.provider)?.trim().toLowerCase();\n\tif (pinned === \"duckduckgo\") {\n\t\treturn { configured: false, explicitKeyless: true };\n\t}\n\n\tfor (const { provider, vars } of SEARCH_CREDENTIAL_ENV) {\n\t\tif (vars.some((name) => hasText(process.env[name]))) {\n\t\t\treturn { configured: true, provider, source: \"env\" };\n\t\t}\n\t}\n\n\tconst providers = search?.providers;\n\tif (hasText(providers?.brave?.api_key)) return { configured: true, provider: \"brave\", source: \"settings\" };\n\tif (hasText(providers?.tavily?.api_key)) return { configured: true, provider: \"tavily\", source: \"settings\" };\n\tif (hasText(providers?.searxng?.base_url)) return { configured: true, provider: \"searxng\", source: \"settings\" };\n\n\treturn { configured: false };\n}\n\n// Warn at most once per distinct message for the life of the process.\nconst warnedWebtoolsMessages = new Set<string>();\nfunction warnOnce(message: string): void {\n\tif (warnedWebtoolsMessages.has(message)) return;\n\twarnedWebtoolsMessages.add(message);\n\tconsole.warn(chalk.yellow(`[webtools] ${message}`));\n}\n\n/** True only when `path` is a readable regular file; warns once and returns false otherwise. */\nfunction isReadableFile(path: string): boolean {\n\ttry {\n\t\tif (!statSync(path).isFile()) {\n\t\t\twarnOnce(`--ca-cert path is not a regular file, ignoring: ${path}`);\n\t\t\treturn false;\n\t\t}\n\t\taccessSync(path, constants.R_OK);\n\t\treturn true;\n\t} catch (error) {\n\t\tconst reason = error instanceof Error ? error.message : String(error);\n\t\twarnOnce(`--ca-cert path is not readable, ignoring: ${path} (${reason})`);\n\t\treturn false;\n\t}\n}\n\n/** Build the TLS-related argv flags forwarded to the binary (argv array, no shell). */\nfunction buildTLSArgs(config: WebtoolsTLSConfig | undefined): string[] {\n\tconst flags: string[] = [];\n\tif (!config) return flags;\n\tif (config.caCertPath && isReadableFile(config.caCertPath)) {\n\t\tflags.push(\"--ca-cert\", config.caCertPath);\n\t}\n\tif (config.insecure) {\n\t\twarnOnce(\n\t\t\t\"webtools running with --insecure: TLS verification is DISABLED for webfetch/websearch. \" +\n\t\t\t\t\"Prefer HOOCODE_WEBTOOLS_CA_CERT to trust your proxy's CA with verification kept on.\",\n\t\t);\n\t\tflags.push(\"--insecure\");\n\t}\n\treturn flags;\n}\n\n/**\n * The binary bounds a whole fetch (redirects + retries) at this multiple of the\n * per-request `--timeout`, so the spawn must outlive that or we kill a fetch the\n * binary would have finished. Search has no such budget, but it may try a\n * fallback provider after the primary fails, so it gets two requests' worth.\n */\nconst WHOLE_RUN_TIMEOUT_MULTIPLIER: Record<\"fetch\" | \"search\", number> = {\n\tfetch: 3,\n\tsearch: 2,\n};\n\n/** Extra wall-clock headroom (seconds) so the binary reports its own timeout before we kill it. */\nconst SPAWN_TIMEOUT_HEADROOM_SECS = 5;\n\n/**\n * Turn a non-zero exit into the most specific message available. `search` exits\n * non-zero on a blocked provider but writes its JSON (carrying `status`) to\n * stdout with nothing on stderr, so the generic \"exited with code 1\" would throw\n * away the only useful detail.\n */\nfunction describeFailedRun(subcommand: \"fetch\" | \"search\", stdout: string, stderr: string, code: number): string {\n\tconst trimmedStderr = stderr.trim();\n\tif (trimmedStderr) return trimmedStderr;\n\n\ttry {\n\t\tconst parsed = JSON.parse(stdout) as { status?: string };\n\t\tif (parsed?.status === \"blocked\") {\n\t\t\treturn (\n\t\t\t\t\"web search was blocked by the provider (bot challenge or rate limit) rather than returning no results — \" +\n\t\t\t\t\"retry later, or configure a different search provider\"\n\t\t\t);\n\t\t}\n\t} catch {\n\t\t// Not JSON: fall through to the generic message.\n\t}\n\treturn `webtools ${subcommand} exited with code ${code}`;\n}\n\n/**\n * Run a `webtools` subcommand with `--json` and return parsed stdout.\n *\n * Throws on missing binary, non-zero exit (surfacing the binary's stderr, or the\n * status carried on stdout when stderr is empty), or unparseable output. Callers\n * convert thrown errors into tool error results.\n */\nexport async function runWebtools<T>(\n\tsubcommand: \"fetch\" | \"search\",\n\targs: string[],\n\tcwd: string,\n\tsignal?: AbortSignal,\n\ttimeoutSecs: number = WEBTOOLS_DEFAULT_TIMEOUT_SECS,\n\ttlsConfig?: WebtoolsTLSConfig,\n): Promise<T> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\tconst binaryPath = await ensureTool(\"webtools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\n\t// Give the spawn headroom over the binary's own worst-case runtime so the\n\t// binary reports the timeout itself rather than being killed mid-flight.\n\tconst wholeRunSecs = timeoutSecs * WHOLE_RUN_TIMEOUT_MULTIPLIER[subcommand];\n\tconst spawnTimeoutMs = (wholeRunSecs + SPAWN_TIMEOUT_HEADROOM_SECS) * 1000;\n\tconst tlsArgs = buildTLSArgs(tlsConfig);\n\t// `--timeout` must be forwarded: without it the binary falls back to its own\n\t// default and the resolved setting/env value would never reach the request.\n\tconst result = await execCommand(\n\t\tbinaryPath,\n\t\t[subcommand, ...args, \"--timeout\", String(timeoutSecs), ...tlsArgs, \"--json\"],\n\t\tcwd,\n\t\t{\n\t\t\tsignal,\n\t\t\ttimeout: spawnTimeoutMs,\n\t\t},\n\t);\n\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`webtools ${subcommand} timed out after ${wholeRunSecs}s`);\n\tif (result.code !== 0) {\n\t\tthrow new Error(describeFailedRun(subcommand, result.stdout, result.stderr, result.code));\n\t}\n\n\ttry {\n\t\treturn JSON.parse(result.stdout) as T;\n\t} catch {\n\t\tthrow new Error(`webtools ${subcommand} returned malformed JSON`);\n\t}\n}\n\n// ============================================================================\n// Result cache (per-process, short TTL)\n// ============================================================================\n\ninterface CacheEntry<T> {\n\tvalue: T;\n\texpiresAt: number;\n}\n\n/**\n * A computation shared by every caller that requested the same key while it was\n * still running. The subprocess is only aborted once *all* joined callers have\n * aborted, tracked by {@link refCount} against a shared {@link controller}.\n */\ninterface InFlightEntry<T> {\n\tpromise: Promise<T>;\n\tcontroller: AbortController;\n\trefCount: number;\n}\n\nexport class WebToolsCache<T> {\n\tprivate readonly entries = new Map<string, CacheEntry<T>>();\n\tprivate readonly inflight = new Map<string, InFlightEntry<T>>();\n\n\tget(key: string): T | undefined {\n\t\tconst entry = this.entries.get(key);\n\t\tif (!entry) return undefined;\n\t\tif (Date.now() >= entry.expiresAt) {\n\t\t\tthis.entries.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: T): void {\n\t\tthis.entries.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });\n\t}\n\n\t/**\n\t * Return a cached value, join an identical in-flight computation, or start a\n\t * new one — collapsing concurrent duplicate fetch/search calls onto a single\n\t * subprocess. Successful results are cached; failures are not.\n\t *\n\t * Cancellation is shared safely: a caller whose own `signal` aborts rejects\n\t * promptly and releases its reference, but the underlying work keeps running\n\t * for the remaining callers and is only cancelled once none are left.\n\t */\n\tasync getOrCompute(\n\t\tkey: string,\n\t\tsignal: AbortSignal | undefined,\n\t\tcompute: (signal: AbortSignal) => Promise<T>,\n\t): Promise<T> {\n\t\tconst cached = this.get(key);\n\t\tif (cached !== undefined) return cached;\n\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\tlet entry = this.inflight.get(key);\n\t\tif (!entry) {\n\t\t\tconst controller = new AbortController();\n\t\t\tconst promise = (async () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst value = await compute(controller.signal);\n\t\t\t\t\tthis.set(key, value);\n\t\t\t\t\treturn value;\n\t\t\t\t} finally {\n\t\t\t\t\tthis.inflight.delete(key);\n\t\t\t\t}\n\t\t\t})();\n\t\t\tentry = { promise, controller, refCount: 0 };\n\t\t\tthis.inflight.set(key, entry);\n\t\t}\n\n\t\tconst joined = entry;\n\t\t// Every joined caller (signalled or not) holds a reference; the shared work\n\t\t// is cancelled only when an abort drops the count back to zero.\n\t\tjoined.refCount++;\n\n\t\tif (!signal) {\n\t\t\treturn joined.promise;\n\t\t}\n\n\t\tconst onAbort = () => {\n\t\t\tif (joined.refCount > 0) joined.refCount--;\n\t\t\tif (joined.refCount === 0) joined.controller.abort();\n\t\t};\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t\ttry {\n\t\t\treturn await Promise.race([\n\t\t\t\tjoined.promise,\n\t\t\t\tnew Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Operation aborted\")), { once: true });\n\t\t\t\t}),\n\t\t\t]);\n\t\t} finally {\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t}\n\t}\n}\n\n// ============================================================================\n// .webtoolsignore policy matcher\n// ============================================================================\n\n/**\n * Memoize the parsed matcher per cwd. The policy is consulted on every webfetch\n * (twice: permission gate + tool execute) and every websearch, so re-reading and\n * re-parsing three files each time is wasted sync I/O on the hot path. The cache\n * is invalidated by a cheap stat signature (existence + mtime + size) so an\n * edited `.webtoolsignore` still takes effect immediately — correctness matters\n * here because this gate enforces host policy.\n */\ninterface IgnoreCacheEntry {\n\tsignature: string;\n\tmatcher: IgnoreMatcher | undefined;\n}\nconst ignoreCacheByCwd = new Map<string, IgnoreCacheEntry>();\n\nfunction ignoreSignature(files: string[]): string {\n\treturn files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\tconst st = statSync(file);\n\t\t\t\treturn `${file}:${st.mtimeMs}:${st.size}`;\n\t\t\t} catch {\n\t\t\t\treturn `${file}:absent`;\n\t\t\t}\n\t\t})\n\t\t.join(\"|\");\n}\n\n/**\n * Build an {@link Ignore} matcher from `.webtoolsignore` policy files.\n *\n * Precedence is project-after-user so a project file can re-allow (`!host`)\n * something the user blocked, matching gitignore layering. Returns undefined\n * when no policy files exist (the common case: everything allowed).\n *\n * Hosts are matched as single path components, so subdomains need an explicit\n * wildcard (`*.example.com`), exactly like gitignore directory matching.\n */\nexport function loadWebtoolsIgnore(cwd: string): IgnoreMatcher | undefined {\n\tconst files = [\n\t\tjoin(getAgentDir(), \"webtoolsignore\"),\n\t\tjoin(homedir(), \".webtoolsignore\"),\n\t\tjoin(cwd, \".webtoolsignore\"),\n\t];\n\n\tconst signature = ignoreSignature(files);\n\tconst cached = ignoreCacheByCwd.get(cwd);\n\tif (cached && cached.signature === signature) {\n\t\treturn cached.matcher;\n\t}\n\n\tlet found = false;\n\tconst ig = ignore();\n\tfor (const file of files) {\n\t\tif (!existsSync(file)) continue;\n\t\ttry {\n\t\t\tig.add(readFileSync(file, \"utf8\"));\n\t\t\tfound = true;\n\t\t} catch {\n\t\t\t// Unreadable policy file: ignore it rather than failing the tool call.\n\t\t}\n\t}\n\tconst matcher = found ? ig : undefined;\n\tignoreCacheByCwd.set(cwd, { signature, matcher });\n\treturn matcher;\n}\n\n/** Extract the lowercased hostname from a URL, or undefined if it cannot be parsed. */\nexport function hostnameOf(url: string): string | undefined {\n\ttry {\n\t\tconst host = new URL(url).hostname.toLowerCase();\n\t\treturn host || undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Whether a host is blocked by policy. A matcher is required; with no policy\n * files present callers treat every host as allowed.\n */\nexport function isHostBlocked(matcher: IgnoreMatcher, host: string): boolean {\n\tif (!host) return false;\n\treturn matcher.ignores(host);\n}\n\n/**\n * Convenience used by the permission gate: returns the blocked host for a URL,\n * or undefined when the URL is allowed (or there is no policy / unparseable URL).\n */\nexport function blockedHostForUrl(cwd: string, url: string): string | undefined {\n\tconst matcher = loadWebtoolsIgnore(cwd);\n\tif (!matcher) return undefined;\n\tconst host = hostnameOf(url);\n\tif (!host) return undefined;\n\treturn isHostBlocked(matcher, host) ? host : undefined;\n}\n"]}
@@ -6,9 +6,10 @@
6
6
  * This module owns:
7
7
  * - the spawn-and-parse runner,
8
8
  * - the locked JSON result types,
9
- * - a short-lived in-process result cache, and
9
+ * - a short-lived in-process result cache,
10
10
  * - the `.webtoolsignore` policy matcher (gitignore semantics) used to block
11
- * hosts both before a fetch and when filtering search result links.
11
+ * hosts both before a fetch and when filtering search result links, and
12
+ * - the read-only check for whether `websearch` has a keyed backend configured.
12
13
  */
13
14
  import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
14
15
  import { homedir } from "node:os";
@@ -84,6 +85,46 @@ export function resolveWebtoolsTimeoutSecs(override) {
84
85
  }
85
86
  return WEBTOOLS_DEFAULT_TIMEOUT_SECS;
86
87
  }
88
+ /** Env var names per keyed provider, in the precedence the binary applies. */
89
+ const SEARCH_CREDENTIAL_ENV = [
90
+ { provider: "brave", vars: ["WEBTOOLS_BRAVE_API_KEY", "BRAVE_API_KEY"] },
91
+ { provider: "tavily", vars: ["WEBTOOLS_TAVILY_API_KEY", "TAVILY_API_KEY"] },
92
+ // SearXNG is self-hosted: the endpoint is the credential, its key optional.
93
+ { provider: "searxng", vars: ["WEBTOOLS_SEARXNG_URL"] },
94
+ ];
95
+ function hasText(value) {
96
+ return typeof value === "string" && value.trim().length > 0;
97
+ }
98
+ /**
99
+ * Whether `websearch` has a keyed backend configured, and where it came from.
100
+ *
101
+ * Mirrors the binary's own resolution order (env over settings file) for the
102
+ * three keyed backends. This is a read-only check used to decide whether to
103
+ * tell the user that search is running on keyless DuckDuckGo — it never
104
+ * returns the credential itself, so a key cannot leak into the UI or a log.
105
+ *
106
+ * A provider pinned to `duckduckgo` (env or settings) is reported as
107
+ * `explicitKeyless`: the user chose the scraped backend, so nothing is missing.
108
+ */
109
+ export function resolveWebSearchCredentials(search) {
110
+ const pinned = (process.env.WEBTOOLS_SEARCH_PROVIDER ?? search?.provider)?.trim().toLowerCase();
111
+ if (pinned === "duckduckgo") {
112
+ return { configured: false, explicitKeyless: true };
113
+ }
114
+ for (const { provider, vars } of SEARCH_CREDENTIAL_ENV) {
115
+ if (vars.some((name) => hasText(process.env[name]))) {
116
+ return { configured: true, provider, source: "env" };
117
+ }
118
+ }
119
+ const providers = search?.providers;
120
+ if (hasText(providers?.brave?.api_key))
121
+ return { configured: true, provider: "brave", source: "settings" };
122
+ if (hasText(providers?.tavily?.api_key))
123
+ return { configured: true, provider: "tavily", source: "settings" };
124
+ if (hasText(providers?.searxng?.base_url))
125
+ return { configured: true, provider: "searxng", source: "settings" };
126
+ return { configured: false };
127
+ }
87
128
  // Warn at most once per distinct message for the life of the process.
88
129
  const warnedWebtoolsMessages = new Set();
89
130
  function warnOnce(message) {
@@ -1 +1 @@
1
- {"version":3,"file":"webtools-shared.js","sourceRoot":"","sources":["../../../src/core/tools/webtools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `webfetch` and `websearch` tools.\n *\n * Both tools shell out to the `webtools` binary (fetch / search subcommands,\n * resolved/downloaded via {@link ensureTool}) and parse its `--json` output.\n * This module owns:\n * - the spawn-and-parse runner,\n * - the locked JSON result types,\n * - a short-lived in-process result cache, and\n * - the `.webtoolsignore` policy matcher (gitignore semantics) used to block\n * hosts both before a fetch and when filtering search result links.\n */\n\nimport { accessSync, constants, existsSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport chalk from \"chalk\";\nimport ignore from \"ignore\";\nimport { getAgentDir } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\n/** Default request timeout (seconds) passed to the binary. */\nconst WEBTOOLS_DEFAULT_TIMEOUT_SECS = 15;\n\n/** Lower/upper bounds on the effective request timeout (seconds). */\nconst WEBTOOLS_MIN_TIMEOUT_SECS = 1;\nconst WEBTOOLS_MAX_TIMEOUT_SECS = 120;\n\n/** How long a successful result stays cached, mirroring the documented 15-min TTL. */\nconst CACHE_TTL_MS = 15 * 60 * 1000;\n\n// ============================================================================\n// Result types (locked against `webtools <cmd> --json`)\n// ============================================================================\n\n/**\n * Whether the binary actually extracted content (`FetchResult.status`).\n *\n * Optional here because an older `webtools` on PATH predates the field; absent\n * is treated as `ok`. Without this, a JavaScript-rendered shell and a genuinely\n * blank page are both \"empty content, exit 0\" and the model reads either as\n * \"this page has nothing to say\".\n */\nexport type WebFetchContentStatus = \"ok\" | \"empty\" | \"needs_js\" | \"too_complex\";\n\n/** Whether the search answered (`SearchOutput.status`). See {@link WebFetchContentStatus} on optionality. */\nexport type WebSearchStatus = \"ok\" | \"empty\" | \"blocked\";\n\n/** One-line explanation for a non-`ok` fetch status, mirroring the binary's own note. */\nexport function fetchStatusNote(status: WebFetchContentStatus | undefined): string | undefined {\n\tswitch (status) {\n\t\tcase \"empty\":\n\t\t\treturn \"the page parsed but contains no text\";\n\t\tcase \"needs_js\":\n\t\t\treturn \"no text content: the page renders its body with JavaScript, which webtools does not execute\";\n\t\tcase \"too_complex\":\n\t\t\treturn \"the document is too deeply nested to parse safely and was refused\";\n\t\tdefault:\n\t\t\treturn undefined;\n\t}\n}\n\ninterface WebFetchReference {\n\tindex: number;\n\turl: string;\n\ttext?: string;\n}\n\ninterface WebFetchMetadata {\n\tdescription?: string;\n\tauthor?: string;\n\tpublished?: string;\n\tlang?: string;\n\tsite_name?: string;\n}\n\nexport interface WebFetchResult {\n\ttitle?: string;\n\tfinal_url: string;\n\tcontent: string;\n\tcontent_type: string;\n\tmedia: string;\n\ttoken_estimate: number;\n\t/** Absent on binaries older than the status field; treated as \"ok\". */\n\tstatus?: WebFetchContentStatus;\n\treferences: WebFetchReference[];\n\tmetadata?: WebFetchMetadata;\n\t/** The URL that was requested, before any redirect (`final_url` is post-redirect). */\n\tsource: string;\n}\n\nexport interface WebSearchResultItem {\n\ttitle: string;\n\tsnippet: string;\n\turl: string;\n\tref_index: number;\n}\n\ninterface WebSearchReference {\n\tindex: number;\n\turl: string;\n}\n\nexport interface WebSearchOutput {\n\tquery: string;\n\tresults: WebSearchResultItem[];\n\treferences: WebSearchReference[];\n\ttoken_estimate: number;\n\tresult_count: number;\n\t/** Absent on binaries older than the status field; treated as \"ok\". */\n\tstatus?: WebSearchStatus;\n\t/** Which backend answered, so a silent fallback to DuckDuckGo stays visible. */\n\tprovider?: string;\n}\n\n// ============================================================================\n// Binary runner\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"webtools binary unavailable and could not be downloaded — web tools require the `webtools` CLI on PATH or a published release for this platform\";\n\n/**\n * TLS plumbing forwarded to the `webtools` binary for `webfetch`/`websearch`.\n * Kept separate from hoocode's own app-level TLS trust (utils/tls-ca.ts): the\n * binary has its own TLS stack, so it needs the CA / insecure flag passed in.\n */\nexport interface WebtoolsTLSConfig {\n\t/** Path to a PEM CA bundle forwarded as `--ca-cert <path>` (validated readable). */\n\tcaCertPath?: string;\n\t/** Forward `--insecure` (disables TLS verification in the binary). Strictly opt-in. */\n\tinsecure?: boolean;\n}\n\nfunction isTruthyEnv(value: string | undefined): boolean {\n\tif (!value) return false;\n\tconst normalized = value.trim().toLowerCase();\n\treturn normalized === \"1\" || normalized === \"true\" || normalized === \"yes\";\n}\n\n/**\n * Resolve the webtools TLS config from explicit overrides (e.g. settings.json\n * passed down from the tool factories) falling back to the environment\n * (`HOOCODE_WEBTOOLS_CA_CERT`, `HOOCODE_WEBTOOLS_INSECURE`). Never hardcoded.\n */\nexport function resolveWebtoolsTLSConfig(overrides?: WebtoolsTLSConfig): WebtoolsTLSConfig {\n\tconst envCaCert = process.env.HOOCODE_WEBTOOLS_CA_CERT?.trim();\n\tconst caCertPath = overrides?.caCertPath ?? (envCaCert && envCaCert.length > 0 ? envCaCert : undefined);\n\tconst insecure = overrides?.insecure ?? isTruthyEnv(process.env.HOOCODE_WEBTOOLS_INSECURE);\n\treturn { caCertPath, insecure };\n}\n\n/** Clamp a request timeout to the supported range, flooring to whole seconds. */\nfunction clampTimeoutSecs(secs: number): number {\n\treturn Math.min(WEBTOOLS_MAX_TIMEOUT_SECS, Math.max(WEBTOOLS_MIN_TIMEOUT_SECS, Math.floor(secs)));\n}\n\n/**\n * Resolve the effective webtools request timeout (seconds) from an explicit\n * override (e.g. settings.json passed down from the tool factories) falling back\n * to the environment (`HOOCODE_WEBTOOLS_TIMEOUT`) and finally the default. Mirrors\n * {@link resolveWebtoolsTLSConfig}: resolve once, thread in, never hardcode. A\n * malformed or out-of-range env value falls back to the default; every result is\n * clamped to [1, 120].\n */\nexport function resolveWebtoolsTimeoutSecs(override?: number): number {\n\tif (override !== undefined && Number.isFinite(override)) {\n\t\treturn clampTimeoutSecs(override);\n\t}\n\tconst envRaw = process.env.HOOCODE_WEBTOOLS_TIMEOUT?.trim();\n\tif (envRaw) {\n\t\tconst envValue = Number(envRaw);\n\t\tif (Number.isFinite(envValue) && envValue > 0) {\n\t\t\treturn clampTimeoutSecs(envValue);\n\t\t}\n\t}\n\treturn WEBTOOLS_DEFAULT_TIMEOUT_SECS;\n}\n\n// Warn at most once per distinct message for the life of the process.\nconst warnedWebtoolsMessages = new Set<string>();\nfunction warnOnce(message: string): void {\n\tif (warnedWebtoolsMessages.has(message)) return;\n\twarnedWebtoolsMessages.add(message);\n\tconsole.warn(chalk.yellow(`[webtools] ${message}`));\n}\n\n/** True only when `path` is a readable regular file; warns once and returns false otherwise. */\nfunction isReadableFile(path: string): boolean {\n\ttry {\n\t\tif (!statSync(path).isFile()) {\n\t\t\twarnOnce(`--ca-cert path is not a regular file, ignoring: ${path}`);\n\t\t\treturn false;\n\t\t}\n\t\taccessSync(path, constants.R_OK);\n\t\treturn true;\n\t} catch (error) {\n\t\tconst reason = error instanceof Error ? error.message : String(error);\n\t\twarnOnce(`--ca-cert path is not readable, ignoring: ${path} (${reason})`);\n\t\treturn false;\n\t}\n}\n\n/** Build the TLS-related argv flags forwarded to the binary (argv array, no shell). */\nfunction buildTLSArgs(config: WebtoolsTLSConfig | undefined): string[] {\n\tconst flags: string[] = [];\n\tif (!config) return flags;\n\tif (config.caCertPath && isReadableFile(config.caCertPath)) {\n\t\tflags.push(\"--ca-cert\", config.caCertPath);\n\t}\n\tif (config.insecure) {\n\t\twarnOnce(\n\t\t\t\"webtools running with --insecure: TLS verification is DISABLED for webfetch/websearch. \" +\n\t\t\t\t\"Prefer HOOCODE_WEBTOOLS_CA_CERT to trust your proxy's CA with verification kept on.\",\n\t\t);\n\t\tflags.push(\"--insecure\");\n\t}\n\treturn flags;\n}\n\n/**\n * The binary bounds a whole fetch (redirects + retries) at this multiple of the\n * per-request `--timeout`, so the spawn must outlive that or we kill a fetch the\n * binary would have finished. Search has no such budget, but it may try a\n * fallback provider after the primary fails, so it gets two requests' worth.\n */\nconst WHOLE_RUN_TIMEOUT_MULTIPLIER: Record<\"fetch\" | \"search\", number> = {\n\tfetch: 3,\n\tsearch: 2,\n};\n\n/** Extra wall-clock headroom (seconds) so the binary reports its own timeout before we kill it. */\nconst SPAWN_TIMEOUT_HEADROOM_SECS = 5;\n\n/**\n * Turn a non-zero exit into the most specific message available. `search` exits\n * non-zero on a blocked provider but writes its JSON (carrying `status`) to\n * stdout with nothing on stderr, so the generic \"exited with code 1\" would throw\n * away the only useful detail.\n */\nfunction describeFailedRun(subcommand: \"fetch\" | \"search\", stdout: string, stderr: string, code: number): string {\n\tconst trimmedStderr = stderr.trim();\n\tif (trimmedStderr) return trimmedStderr;\n\n\ttry {\n\t\tconst parsed = JSON.parse(stdout) as { status?: string };\n\t\tif (parsed?.status === \"blocked\") {\n\t\t\treturn (\n\t\t\t\t\"web search was blocked by the provider (bot challenge or rate limit) rather than returning no results — \" +\n\t\t\t\t\"retry later, or configure a different search provider\"\n\t\t\t);\n\t\t}\n\t} catch {\n\t\t// Not JSON: fall through to the generic message.\n\t}\n\treturn `webtools ${subcommand} exited with code ${code}`;\n}\n\n/**\n * Run a `webtools` subcommand with `--json` and return parsed stdout.\n *\n * Throws on missing binary, non-zero exit (surfacing the binary's stderr, or the\n * status carried on stdout when stderr is empty), or unparseable output. Callers\n * convert thrown errors into tool error results.\n */\nexport async function runWebtools<T>(\n\tsubcommand: \"fetch\" | \"search\",\n\targs: string[],\n\tcwd: string,\n\tsignal?: AbortSignal,\n\ttimeoutSecs: number = WEBTOOLS_DEFAULT_TIMEOUT_SECS,\n\ttlsConfig?: WebtoolsTLSConfig,\n): Promise<T> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\tconst binaryPath = await ensureTool(\"webtools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\n\t// Give the spawn headroom over the binary's own worst-case runtime so the\n\t// binary reports the timeout itself rather than being killed mid-flight.\n\tconst wholeRunSecs = timeoutSecs * WHOLE_RUN_TIMEOUT_MULTIPLIER[subcommand];\n\tconst spawnTimeoutMs = (wholeRunSecs + SPAWN_TIMEOUT_HEADROOM_SECS) * 1000;\n\tconst tlsArgs = buildTLSArgs(tlsConfig);\n\t// `--timeout` must be forwarded: without it the binary falls back to its own\n\t// default and the resolved setting/env value would never reach the request.\n\tconst result = await execCommand(\n\t\tbinaryPath,\n\t\t[subcommand, ...args, \"--timeout\", String(timeoutSecs), ...tlsArgs, \"--json\"],\n\t\tcwd,\n\t\t{\n\t\t\tsignal,\n\t\t\ttimeout: spawnTimeoutMs,\n\t\t},\n\t);\n\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`webtools ${subcommand} timed out after ${wholeRunSecs}s`);\n\tif (result.code !== 0) {\n\t\tthrow new Error(describeFailedRun(subcommand, result.stdout, result.stderr, result.code));\n\t}\n\n\ttry {\n\t\treturn JSON.parse(result.stdout) as T;\n\t} catch {\n\t\tthrow new Error(`webtools ${subcommand} returned malformed JSON`);\n\t}\n}\n\n// ============================================================================\n// Result cache (per-process, short TTL)\n// ============================================================================\n\ninterface CacheEntry<T> {\n\tvalue: T;\n\texpiresAt: number;\n}\n\n/**\n * A computation shared by every caller that requested the same key while it was\n * still running. The subprocess is only aborted once *all* joined callers have\n * aborted, tracked by {@link refCount} against a shared {@link controller}.\n */\ninterface InFlightEntry<T> {\n\tpromise: Promise<T>;\n\tcontroller: AbortController;\n\trefCount: number;\n}\n\nexport class WebToolsCache<T> {\n\tprivate readonly entries = new Map<string, CacheEntry<T>>();\n\tprivate readonly inflight = new Map<string, InFlightEntry<T>>();\n\n\tget(key: string): T | undefined {\n\t\tconst entry = this.entries.get(key);\n\t\tif (!entry) return undefined;\n\t\tif (Date.now() >= entry.expiresAt) {\n\t\t\tthis.entries.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: T): void {\n\t\tthis.entries.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });\n\t}\n\n\t/**\n\t * Return a cached value, join an identical in-flight computation, or start a\n\t * new one — collapsing concurrent duplicate fetch/search calls onto a single\n\t * subprocess. Successful results are cached; failures are not.\n\t *\n\t * Cancellation is shared safely: a caller whose own `signal` aborts rejects\n\t * promptly and releases its reference, but the underlying work keeps running\n\t * for the remaining callers and is only cancelled once none are left.\n\t */\n\tasync getOrCompute(\n\t\tkey: string,\n\t\tsignal: AbortSignal | undefined,\n\t\tcompute: (signal: AbortSignal) => Promise<T>,\n\t): Promise<T> {\n\t\tconst cached = this.get(key);\n\t\tif (cached !== undefined) return cached;\n\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\tlet entry = this.inflight.get(key);\n\t\tif (!entry) {\n\t\t\tconst controller = new AbortController();\n\t\t\tconst promise = (async () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst value = await compute(controller.signal);\n\t\t\t\t\tthis.set(key, value);\n\t\t\t\t\treturn value;\n\t\t\t\t} finally {\n\t\t\t\t\tthis.inflight.delete(key);\n\t\t\t\t}\n\t\t\t})();\n\t\t\tentry = { promise, controller, refCount: 0 };\n\t\t\tthis.inflight.set(key, entry);\n\t\t}\n\n\t\tconst joined = entry;\n\t\t// Every joined caller (signalled or not) holds a reference; the shared work\n\t\t// is cancelled only when an abort drops the count back to zero.\n\t\tjoined.refCount++;\n\n\t\tif (!signal) {\n\t\t\treturn joined.promise;\n\t\t}\n\n\t\tconst onAbort = () => {\n\t\t\tif (joined.refCount > 0) joined.refCount--;\n\t\t\tif (joined.refCount === 0) joined.controller.abort();\n\t\t};\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t\ttry {\n\t\t\treturn await Promise.race([\n\t\t\t\tjoined.promise,\n\t\t\t\tnew Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Operation aborted\")), { once: true });\n\t\t\t\t}),\n\t\t\t]);\n\t\t} finally {\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t}\n\t}\n}\n\n// ============================================================================\n// .webtoolsignore policy matcher\n// ============================================================================\n\n/**\n * Memoize the parsed matcher per cwd. The policy is consulted on every webfetch\n * (twice: permission gate + tool execute) and every websearch, so re-reading and\n * re-parsing three files each time is wasted sync I/O on the hot path. The cache\n * is invalidated by a cheap stat signature (existence + mtime + size) so an\n * edited `.webtoolsignore` still takes effect immediately — correctness matters\n * here because this gate enforces host policy.\n */\ninterface IgnoreCacheEntry {\n\tsignature: string;\n\tmatcher: IgnoreMatcher | undefined;\n}\nconst ignoreCacheByCwd = new Map<string, IgnoreCacheEntry>();\n\nfunction ignoreSignature(files: string[]): string {\n\treturn files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\tconst st = statSync(file);\n\t\t\t\treturn `${file}:${st.mtimeMs}:${st.size}`;\n\t\t\t} catch {\n\t\t\t\treturn `${file}:absent`;\n\t\t\t}\n\t\t})\n\t\t.join(\"|\");\n}\n\n/**\n * Build an {@link Ignore} matcher from `.webtoolsignore` policy files.\n *\n * Precedence is project-after-user so a project file can re-allow (`!host`)\n * something the user blocked, matching gitignore layering. Returns undefined\n * when no policy files exist (the common case: everything allowed).\n *\n * Hosts are matched as single path components, so subdomains need an explicit\n * wildcard (`*.example.com`), exactly like gitignore directory matching.\n */\nexport function loadWebtoolsIgnore(cwd: string): IgnoreMatcher | undefined {\n\tconst files = [\n\t\tjoin(getAgentDir(), \"webtoolsignore\"),\n\t\tjoin(homedir(), \".webtoolsignore\"),\n\t\tjoin(cwd, \".webtoolsignore\"),\n\t];\n\n\tconst signature = ignoreSignature(files);\n\tconst cached = ignoreCacheByCwd.get(cwd);\n\tif (cached && cached.signature === signature) {\n\t\treturn cached.matcher;\n\t}\n\n\tlet found = false;\n\tconst ig = ignore();\n\tfor (const file of files) {\n\t\tif (!existsSync(file)) continue;\n\t\ttry {\n\t\t\tig.add(readFileSync(file, \"utf8\"));\n\t\t\tfound = true;\n\t\t} catch {\n\t\t\t// Unreadable policy file: ignore it rather than failing the tool call.\n\t\t}\n\t}\n\tconst matcher = found ? ig : undefined;\n\tignoreCacheByCwd.set(cwd, { signature, matcher });\n\treturn matcher;\n}\n\n/** Extract the lowercased hostname from a URL, or undefined if it cannot be parsed. */\nexport function hostnameOf(url: string): string | undefined {\n\ttry {\n\t\tconst host = new URL(url).hostname.toLowerCase();\n\t\treturn host || undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Whether a host is blocked by policy. A matcher is required; with no policy\n * files present callers treat every host as allowed.\n */\nexport function isHostBlocked(matcher: IgnoreMatcher, host: string): boolean {\n\tif (!host) return false;\n\treturn matcher.ignores(host);\n}\n\n/**\n * Convenience used by the permission gate: returns the blocked host for a URL,\n * or undefined when the URL is allowed (or there is no policy / unparseable URL).\n */\nexport function blockedHostForUrl(cwd: string, url: string): string | undefined {\n\tconst matcher = loadWebtoolsIgnore(cwd);\n\tif (!matcher) return undefined;\n\tconst host = hostnameOf(url);\n\tif (!host) return undefined;\n\treturn isHostBlocked(matcher, host) ? host : undefined;\n}\n"]}
1
+ {"version":3,"file":"webtools-shared.js","sourceRoot":"","sources":["../../../src/core/tools/webtools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `webfetch` and `websearch` tools.\n *\n * Both tools shell out to the `webtools` binary (fetch / search subcommands,\n * resolved/downloaded via {@link ensureTool}) and parse its `--json` output.\n * This module owns:\n * - the spawn-and-parse runner,\n * - the locked JSON result types,\n * - a short-lived in-process result cache,\n * - the `.webtoolsignore` policy matcher (gitignore semantics) used to block\n * hosts both before a fetch and when filtering search result links, and\n * - the read-only check for whether `websearch` has a keyed backend configured.\n */\n\nimport { accessSync, constants, existsSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport chalk from \"chalk\";\nimport ignore from \"ignore\";\nimport { getAgentDir } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\n/** Default request timeout (seconds) passed to the binary. */\nconst WEBTOOLS_DEFAULT_TIMEOUT_SECS = 15;\n\n/** Lower/upper bounds on the effective request timeout (seconds). */\nconst WEBTOOLS_MIN_TIMEOUT_SECS = 1;\nconst WEBTOOLS_MAX_TIMEOUT_SECS = 120;\n\n/** How long a successful result stays cached, mirroring the documented 15-min TTL. */\nconst CACHE_TTL_MS = 15 * 60 * 1000;\n\n// ============================================================================\n// Result types (locked against `webtools <cmd> --json`)\n// ============================================================================\n\n/**\n * Whether the binary actually extracted content (`FetchResult.status`).\n *\n * Optional here because an older `webtools` on PATH predates the field; absent\n * is treated as `ok`. Without this, a JavaScript-rendered shell and a genuinely\n * blank page are both \"empty content, exit 0\" and the model reads either as\n * \"this page has nothing to say\".\n */\nexport type WebFetchContentStatus = \"ok\" | \"empty\" | \"needs_js\" | \"too_complex\";\n\n/** Whether the search answered (`SearchOutput.status`). See {@link WebFetchContentStatus} on optionality. */\nexport type WebSearchStatus = \"ok\" | \"empty\" | \"blocked\";\n\n/** One-line explanation for a non-`ok` fetch status, mirroring the binary's own note. */\nexport function fetchStatusNote(status: WebFetchContentStatus | undefined): string | undefined {\n\tswitch (status) {\n\t\tcase \"empty\":\n\t\t\treturn \"the page parsed but contains no text\";\n\t\tcase \"needs_js\":\n\t\t\treturn \"no text content: the page renders its body with JavaScript, which webtools does not execute\";\n\t\tcase \"too_complex\":\n\t\t\treturn \"the document is too deeply nested to parse safely and was refused\";\n\t\tdefault:\n\t\t\treturn undefined;\n\t}\n}\n\ninterface WebFetchReference {\n\tindex: number;\n\turl: string;\n\ttext?: string;\n}\n\ninterface WebFetchMetadata {\n\tdescription?: string;\n\tauthor?: string;\n\tpublished?: string;\n\tlang?: string;\n\tsite_name?: string;\n}\n\nexport interface WebFetchResult {\n\ttitle?: string;\n\tfinal_url: string;\n\tcontent: string;\n\tcontent_type: string;\n\tmedia: string;\n\ttoken_estimate: number;\n\t/** Absent on binaries older than the status field; treated as \"ok\". */\n\tstatus?: WebFetchContentStatus;\n\treferences: WebFetchReference[];\n\tmetadata?: WebFetchMetadata;\n\t/** The URL that was requested, before any redirect (`final_url` is post-redirect). */\n\tsource: string;\n}\n\nexport interface WebSearchResultItem {\n\ttitle: string;\n\tsnippet: string;\n\turl: string;\n\tref_index: number;\n}\n\ninterface WebSearchReference {\n\tindex: number;\n\turl: string;\n}\n\nexport interface WebSearchOutput {\n\tquery: string;\n\tresults: WebSearchResultItem[];\n\treferences: WebSearchReference[];\n\ttoken_estimate: number;\n\tresult_count: number;\n\t/** Absent on binaries older than the status field; treated as \"ok\". */\n\tstatus?: WebSearchStatus;\n\t/** Which backend answered, so a silent fallback to DuckDuckGo stays visible. */\n\tprovider?: string;\n}\n\n// ============================================================================\n// Binary runner\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"webtools binary unavailable and could not be downloaded — web tools require the `webtools` CLI on PATH or a published release for this platform\";\n\n/**\n * TLS plumbing forwarded to the `webtools` binary for `webfetch`/`websearch`.\n * Kept separate from hoocode's own app-level TLS trust (utils/tls-ca.ts): the\n * binary has its own TLS stack, so it needs the CA / insecure flag passed in.\n */\nexport interface WebtoolsTLSConfig {\n\t/** Path to a PEM CA bundle forwarded as `--ca-cert <path>` (validated readable). */\n\tcaCertPath?: string;\n\t/** Forward `--insecure` (disables TLS verification in the binary). Strictly opt-in. */\n\tinsecure?: boolean;\n}\n\nfunction isTruthyEnv(value: string | undefined): boolean {\n\tif (!value) return false;\n\tconst normalized = value.trim().toLowerCase();\n\treturn normalized === \"1\" || normalized === \"true\" || normalized === \"yes\";\n}\n\n/**\n * Resolve the webtools TLS config from explicit overrides (e.g. settings.json\n * passed down from the tool factories) falling back to the environment\n * (`HOOCODE_WEBTOOLS_CA_CERT`, `HOOCODE_WEBTOOLS_INSECURE`). Never hardcoded.\n */\nexport function resolveWebtoolsTLSConfig(overrides?: WebtoolsTLSConfig): WebtoolsTLSConfig {\n\tconst envCaCert = process.env.HOOCODE_WEBTOOLS_CA_CERT?.trim();\n\tconst caCertPath = overrides?.caCertPath ?? (envCaCert && envCaCert.length > 0 ? envCaCert : undefined);\n\tconst insecure = overrides?.insecure ?? isTruthyEnv(process.env.HOOCODE_WEBTOOLS_INSECURE);\n\treturn { caCertPath, insecure };\n}\n\n/** Clamp a request timeout to the supported range, flooring to whole seconds. */\nfunction clampTimeoutSecs(secs: number): number {\n\treturn Math.min(WEBTOOLS_MAX_TIMEOUT_SECS, Math.max(WEBTOOLS_MIN_TIMEOUT_SECS, Math.floor(secs)));\n}\n\n/**\n * Resolve the effective webtools request timeout (seconds) from an explicit\n * override (e.g. settings.json passed down from the tool factories) falling back\n * to the environment (`HOOCODE_WEBTOOLS_TIMEOUT`) and finally the default. Mirrors\n * {@link resolveWebtoolsTLSConfig}: resolve once, thread in, never hardcode. A\n * malformed or out-of-range env value falls back to the default; every result is\n * clamped to [1, 120].\n */\nexport function resolveWebtoolsTimeoutSecs(override?: number): number {\n\tif (override !== undefined && Number.isFinite(override)) {\n\t\treturn clampTimeoutSecs(override);\n\t}\n\tconst envRaw = process.env.HOOCODE_WEBTOOLS_TIMEOUT?.trim();\n\tif (envRaw) {\n\t\tconst envValue = Number(envRaw);\n\t\tif (Number.isFinite(envValue) && envValue > 0) {\n\t\t\treturn clampTimeoutSecs(envValue);\n\t\t}\n\t}\n\treturn WEBTOOLS_DEFAULT_TIMEOUT_SECS;\n}\n\n// ============================================================================\n// Search provider credentials\n// ============================================================================\n\n/**\n * The `webtools.search` block of `~/.hoocode/settings.json`.\n *\n * hoocode and the binary share that file: the binary reads its own `webtools`\n * key (snake_case, per its own schema) and ignores everything else, so these\n * keys are mirrored verbatim rather than camelCased. hoocode never writes them\n * — it only reads them to tell whether `websearch` has a keyed backend.\n */\nexport interface WebtoolsSearchSettings {\n\t/** Primary backend: \"duckduckgo\" | \"brave\" | \"tavily\" | \"searxng\". */\n\tprovider?: string;\n\t/** Backend tried when the primary fails; \"none\" disables the fallback. */\n\tfallback?: string;\n\tproviders?: {\n\t\tbrave?: { api_key?: string };\n\t\ttavily?: { api_key?: string };\n\t\tsearxng?: { base_url?: string; api_key?: string };\n\t};\n}\n\n/** A search backend that answers over an API contract instead of scraped HTML. */\nexport type KeyedSearchProvider = \"brave\" | \"tavily\" | \"searxng\";\n\nexport interface WebSearchCredentialStatus {\n\t/** A keyed backend is reachable, so search does not depend on scraped DuckDuckGo. */\n\tconfigured: boolean;\n\t/** Which backend the credential belongs to, when one is configured. */\n\tprovider?: KeyedSearchProvider;\n\t/** Where the credential came from — env wins over the settings file. */\n\tsource?: \"env\" | \"settings\";\n\t/** The user explicitly asked for the keyless backend, so nothing is missing. */\n\texplicitKeyless?: boolean;\n}\n\n/** Env var names per keyed provider, in the precedence the binary applies. */\nconst SEARCH_CREDENTIAL_ENV: ReadonlyArray<{ provider: KeyedSearchProvider; vars: readonly string[] }> = [\n\t{ provider: \"brave\", vars: [\"WEBTOOLS_BRAVE_API_KEY\", \"BRAVE_API_KEY\"] },\n\t{ provider: \"tavily\", vars: [\"WEBTOOLS_TAVILY_API_KEY\", \"TAVILY_API_KEY\"] },\n\t// SearXNG is self-hosted: the endpoint is the credential, its key optional.\n\t{ provider: \"searxng\", vars: [\"WEBTOOLS_SEARXNG_URL\"] },\n];\n\nfunction hasText(value: string | undefined): boolean {\n\treturn typeof value === \"string\" && value.trim().length > 0;\n}\n\n/**\n * Whether `websearch` has a keyed backend configured, and where it came from.\n *\n * Mirrors the binary's own resolution order (env over settings file) for the\n * three keyed backends. This is a read-only check used to decide whether to\n * tell the user that search is running on keyless DuckDuckGo — it never\n * returns the credential itself, so a key cannot leak into the UI or a log.\n *\n * A provider pinned to `duckduckgo` (env or settings) is reported as\n * `explicitKeyless`: the user chose the scraped backend, so nothing is missing.\n */\nexport function resolveWebSearchCredentials(search?: WebtoolsSearchSettings): WebSearchCredentialStatus {\n\tconst pinned = (process.env.WEBTOOLS_SEARCH_PROVIDER ?? search?.provider)?.trim().toLowerCase();\n\tif (pinned === \"duckduckgo\") {\n\t\treturn { configured: false, explicitKeyless: true };\n\t}\n\n\tfor (const { provider, vars } of SEARCH_CREDENTIAL_ENV) {\n\t\tif (vars.some((name) => hasText(process.env[name]))) {\n\t\t\treturn { configured: true, provider, source: \"env\" };\n\t\t}\n\t}\n\n\tconst providers = search?.providers;\n\tif (hasText(providers?.brave?.api_key)) return { configured: true, provider: \"brave\", source: \"settings\" };\n\tif (hasText(providers?.tavily?.api_key)) return { configured: true, provider: \"tavily\", source: \"settings\" };\n\tif (hasText(providers?.searxng?.base_url)) return { configured: true, provider: \"searxng\", source: \"settings\" };\n\n\treturn { configured: false };\n}\n\n// Warn at most once per distinct message for the life of the process.\nconst warnedWebtoolsMessages = new Set<string>();\nfunction warnOnce(message: string): void {\n\tif (warnedWebtoolsMessages.has(message)) return;\n\twarnedWebtoolsMessages.add(message);\n\tconsole.warn(chalk.yellow(`[webtools] ${message}`));\n}\n\n/** True only when `path` is a readable regular file; warns once and returns false otherwise. */\nfunction isReadableFile(path: string): boolean {\n\ttry {\n\t\tif (!statSync(path).isFile()) {\n\t\t\twarnOnce(`--ca-cert path is not a regular file, ignoring: ${path}`);\n\t\t\treturn false;\n\t\t}\n\t\taccessSync(path, constants.R_OK);\n\t\treturn true;\n\t} catch (error) {\n\t\tconst reason = error instanceof Error ? error.message : String(error);\n\t\twarnOnce(`--ca-cert path is not readable, ignoring: ${path} (${reason})`);\n\t\treturn false;\n\t}\n}\n\n/** Build the TLS-related argv flags forwarded to the binary (argv array, no shell). */\nfunction buildTLSArgs(config: WebtoolsTLSConfig | undefined): string[] {\n\tconst flags: string[] = [];\n\tif (!config) return flags;\n\tif (config.caCertPath && isReadableFile(config.caCertPath)) {\n\t\tflags.push(\"--ca-cert\", config.caCertPath);\n\t}\n\tif (config.insecure) {\n\t\twarnOnce(\n\t\t\t\"webtools running with --insecure: TLS verification is DISABLED for webfetch/websearch. \" +\n\t\t\t\t\"Prefer HOOCODE_WEBTOOLS_CA_CERT to trust your proxy's CA with verification kept on.\",\n\t\t);\n\t\tflags.push(\"--insecure\");\n\t}\n\treturn flags;\n}\n\n/**\n * The binary bounds a whole fetch (redirects + retries) at this multiple of the\n * per-request `--timeout`, so the spawn must outlive that or we kill a fetch the\n * binary would have finished. Search has no such budget, but it may try a\n * fallback provider after the primary fails, so it gets two requests' worth.\n */\nconst WHOLE_RUN_TIMEOUT_MULTIPLIER: Record<\"fetch\" | \"search\", number> = {\n\tfetch: 3,\n\tsearch: 2,\n};\n\n/** Extra wall-clock headroom (seconds) so the binary reports its own timeout before we kill it. */\nconst SPAWN_TIMEOUT_HEADROOM_SECS = 5;\n\n/**\n * Turn a non-zero exit into the most specific message available. `search` exits\n * non-zero on a blocked provider but writes its JSON (carrying `status`) to\n * stdout with nothing on stderr, so the generic \"exited with code 1\" would throw\n * away the only useful detail.\n */\nfunction describeFailedRun(subcommand: \"fetch\" | \"search\", stdout: string, stderr: string, code: number): string {\n\tconst trimmedStderr = stderr.trim();\n\tif (trimmedStderr) return trimmedStderr;\n\n\ttry {\n\t\tconst parsed = JSON.parse(stdout) as { status?: string };\n\t\tif (parsed?.status === \"blocked\") {\n\t\t\treturn (\n\t\t\t\t\"web search was blocked by the provider (bot challenge or rate limit) rather than returning no results — \" +\n\t\t\t\t\"retry later, or configure a different search provider\"\n\t\t\t);\n\t\t}\n\t} catch {\n\t\t// Not JSON: fall through to the generic message.\n\t}\n\treturn `webtools ${subcommand} exited with code ${code}`;\n}\n\n/**\n * Run a `webtools` subcommand with `--json` and return parsed stdout.\n *\n * Throws on missing binary, non-zero exit (surfacing the binary's stderr, or the\n * status carried on stdout when stderr is empty), or unparseable output. Callers\n * convert thrown errors into tool error results.\n */\nexport async function runWebtools<T>(\n\tsubcommand: \"fetch\" | \"search\",\n\targs: string[],\n\tcwd: string,\n\tsignal?: AbortSignal,\n\ttimeoutSecs: number = WEBTOOLS_DEFAULT_TIMEOUT_SECS,\n\ttlsConfig?: WebtoolsTLSConfig,\n): Promise<T> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\tconst binaryPath = await ensureTool(\"webtools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\n\t// Give the spawn headroom over the binary's own worst-case runtime so the\n\t// binary reports the timeout itself rather than being killed mid-flight.\n\tconst wholeRunSecs = timeoutSecs * WHOLE_RUN_TIMEOUT_MULTIPLIER[subcommand];\n\tconst spawnTimeoutMs = (wholeRunSecs + SPAWN_TIMEOUT_HEADROOM_SECS) * 1000;\n\tconst tlsArgs = buildTLSArgs(tlsConfig);\n\t// `--timeout` must be forwarded: without it the binary falls back to its own\n\t// default and the resolved setting/env value would never reach the request.\n\tconst result = await execCommand(\n\t\tbinaryPath,\n\t\t[subcommand, ...args, \"--timeout\", String(timeoutSecs), ...tlsArgs, \"--json\"],\n\t\tcwd,\n\t\t{\n\t\t\tsignal,\n\t\t\ttimeout: spawnTimeoutMs,\n\t\t},\n\t);\n\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`webtools ${subcommand} timed out after ${wholeRunSecs}s`);\n\tif (result.code !== 0) {\n\t\tthrow new Error(describeFailedRun(subcommand, result.stdout, result.stderr, result.code));\n\t}\n\n\ttry {\n\t\treturn JSON.parse(result.stdout) as T;\n\t} catch {\n\t\tthrow new Error(`webtools ${subcommand} returned malformed JSON`);\n\t}\n}\n\n// ============================================================================\n// Result cache (per-process, short TTL)\n// ============================================================================\n\ninterface CacheEntry<T> {\n\tvalue: T;\n\texpiresAt: number;\n}\n\n/**\n * A computation shared by every caller that requested the same key while it was\n * still running. The subprocess is only aborted once *all* joined callers have\n * aborted, tracked by {@link refCount} against a shared {@link controller}.\n */\ninterface InFlightEntry<T> {\n\tpromise: Promise<T>;\n\tcontroller: AbortController;\n\trefCount: number;\n}\n\nexport class WebToolsCache<T> {\n\tprivate readonly entries = new Map<string, CacheEntry<T>>();\n\tprivate readonly inflight = new Map<string, InFlightEntry<T>>();\n\n\tget(key: string): T | undefined {\n\t\tconst entry = this.entries.get(key);\n\t\tif (!entry) return undefined;\n\t\tif (Date.now() >= entry.expiresAt) {\n\t\t\tthis.entries.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: T): void {\n\t\tthis.entries.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });\n\t}\n\n\t/**\n\t * Return a cached value, join an identical in-flight computation, or start a\n\t * new one — collapsing concurrent duplicate fetch/search calls onto a single\n\t * subprocess. Successful results are cached; failures are not.\n\t *\n\t * Cancellation is shared safely: a caller whose own `signal` aborts rejects\n\t * promptly and releases its reference, but the underlying work keeps running\n\t * for the remaining callers and is only cancelled once none are left.\n\t */\n\tasync getOrCompute(\n\t\tkey: string,\n\t\tsignal: AbortSignal | undefined,\n\t\tcompute: (signal: AbortSignal) => Promise<T>,\n\t): Promise<T> {\n\t\tconst cached = this.get(key);\n\t\tif (cached !== undefined) return cached;\n\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\tlet entry = this.inflight.get(key);\n\t\tif (!entry) {\n\t\t\tconst controller = new AbortController();\n\t\t\tconst promise = (async () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst value = await compute(controller.signal);\n\t\t\t\t\tthis.set(key, value);\n\t\t\t\t\treturn value;\n\t\t\t\t} finally {\n\t\t\t\t\tthis.inflight.delete(key);\n\t\t\t\t}\n\t\t\t})();\n\t\t\tentry = { promise, controller, refCount: 0 };\n\t\t\tthis.inflight.set(key, entry);\n\t\t}\n\n\t\tconst joined = entry;\n\t\t// Every joined caller (signalled or not) holds a reference; the shared work\n\t\t// is cancelled only when an abort drops the count back to zero.\n\t\tjoined.refCount++;\n\n\t\tif (!signal) {\n\t\t\treturn joined.promise;\n\t\t}\n\n\t\tconst onAbort = () => {\n\t\t\tif (joined.refCount > 0) joined.refCount--;\n\t\t\tif (joined.refCount === 0) joined.controller.abort();\n\t\t};\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t\ttry {\n\t\t\treturn await Promise.race([\n\t\t\t\tjoined.promise,\n\t\t\t\tnew Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Operation aborted\")), { once: true });\n\t\t\t\t}),\n\t\t\t]);\n\t\t} finally {\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t}\n\t}\n}\n\n// ============================================================================\n// .webtoolsignore policy matcher\n// ============================================================================\n\n/**\n * Memoize the parsed matcher per cwd. The policy is consulted on every webfetch\n * (twice: permission gate + tool execute) and every websearch, so re-reading and\n * re-parsing three files each time is wasted sync I/O on the hot path. The cache\n * is invalidated by a cheap stat signature (existence + mtime + size) so an\n * edited `.webtoolsignore` still takes effect immediately — correctness matters\n * here because this gate enforces host policy.\n */\ninterface IgnoreCacheEntry {\n\tsignature: string;\n\tmatcher: IgnoreMatcher | undefined;\n}\nconst ignoreCacheByCwd = new Map<string, IgnoreCacheEntry>();\n\nfunction ignoreSignature(files: string[]): string {\n\treturn files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\tconst st = statSync(file);\n\t\t\t\treturn `${file}:${st.mtimeMs}:${st.size}`;\n\t\t\t} catch {\n\t\t\t\treturn `${file}:absent`;\n\t\t\t}\n\t\t})\n\t\t.join(\"|\");\n}\n\n/**\n * Build an {@link Ignore} matcher from `.webtoolsignore` policy files.\n *\n * Precedence is project-after-user so a project file can re-allow (`!host`)\n * something the user blocked, matching gitignore layering. Returns undefined\n * when no policy files exist (the common case: everything allowed).\n *\n * Hosts are matched as single path components, so subdomains need an explicit\n * wildcard (`*.example.com`), exactly like gitignore directory matching.\n */\nexport function loadWebtoolsIgnore(cwd: string): IgnoreMatcher | undefined {\n\tconst files = [\n\t\tjoin(getAgentDir(), \"webtoolsignore\"),\n\t\tjoin(homedir(), \".webtoolsignore\"),\n\t\tjoin(cwd, \".webtoolsignore\"),\n\t];\n\n\tconst signature = ignoreSignature(files);\n\tconst cached = ignoreCacheByCwd.get(cwd);\n\tif (cached && cached.signature === signature) {\n\t\treturn cached.matcher;\n\t}\n\n\tlet found = false;\n\tconst ig = ignore();\n\tfor (const file of files) {\n\t\tif (!existsSync(file)) continue;\n\t\ttry {\n\t\t\tig.add(readFileSync(file, \"utf8\"));\n\t\t\tfound = true;\n\t\t} catch {\n\t\t\t// Unreadable policy file: ignore it rather than failing the tool call.\n\t\t}\n\t}\n\tconst matcher = found ? ig : undefined;\n\tignoreCacheByCwd.set(cwd, { signature, matcher });\n\treturn matcher;\n}\n\n/** Extract the lowercased hostname from a URL, or undefined if it cannot be parsed. */\nexport function hostnameOf(url: string): string | undefined {\n\ttry {\n\t\tconst host = new URL(url).hostname.toLowerCase();\n\t\treturn host || undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Whether a host is blocked by policy. A matcher is required; with no policy\n * files present callers treat every host as allowed.\n */\nexport function isHostBlocked(matcher: IgnoreMatcher, host: string): boolean {\n\tif (!host) return false;\n\treturn matcher.ignores(host);\n}\n\n/**\n * Convenience used by the permission gate: returns the blocked host for a URL,\n * or undefined when the URL is allowed (or there is no policy / unparseable URL).\n */\nexport function blockedHostForUrl(cwd: string, url: string): string | undefined {\n\tconst matcher = loadWebtoolsIgnore(cwd);\n\tif (!matcher) return undefined;\n\tconst host = hostnameOf(url);\n\tif (!host) return undefined;\n\treturn isHostBlocked(matcher, host) ? host : undefined;\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/canvas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAYH,OAAO,EAGN,KAAK,oBAAoB,EAEzB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,gCAAgC,CAAC;AAwD5F;;;;;;;GAOG;AACH,MAAM,WAAW,oBAAoB;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,oBAAoB,CAAC,gBAAgB,CAAC,CAAC;CACxD;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAuapF","sourcesContent":["/**\n * `/canvas` — the interactive surface for canvas extensions.\n *\n * Design: `docs/canvas-extensions-design.md` §11. Deliberately thin: every decision\n * lives in `core/canvas/session.ts`, which is testable without a terminal, so this\n * file only renders and supplies an `AbortSignal`.\n *\n * The signal is the point of the loader. `BorderedLoader` already gives Esc-to-cancel\n * and exposes an `AbortSignal`, and `registry.open` accepts one — so a person's Esc\n * reaches the abandon path (§11.6) and the extension is told to release the port it\n * may already have bound, rather than the spinner merely disappearing.\n *\n * `/new-canvas` is registered here rather than beside `/new-skill` and friends\n * because it is not a file-writing command any more: it opens what it scaffolds\n * and hands the agent a brief to build it, which needs this file's session and\n * `pi.sendUserMessage`. Its decisions live in `core/canvas/scaffold.ts`.\n *\n * The agent tools register on the first successful open and stay for the session:\n * `registerTool` has no counterpart to remove a tool. So a session that never opens a\n * canvas pays nothing for them, which is the case that matters (§11.5); after the\n * first open they cost ~235 tokens and answer honestly when nothing is open.\n */\n\nimport { homedir } from \"node:os\";\nimport { getAgentDir } from \"../../config.js\";\nimport { CATEGORY_GLYPH } from \"../../core/brand.js\";\n\n/** Canvas extensions are extensions, so they wear the extension glyph. */\nconst GLYPH = CATEGORY_GLYPH.extensions;\n\nimport { isCanvasRefusal } from \"../../core/canvas/lifecycle.js\";\nimport type { CanvasInstance } from \"../../core/canvas/registry.js\";\nimport { CANVAS_HOMES, canvasBuildBrief, parseCanvasRequest, scaffoldCanvas } from \"../../core/canvas/scaffold.js\";\nimport {\n\ttype CanvasOverview,\n\tCanvasSession,\n\ttype CanvasSessionOptions,\n\tparseCanvasRef,\n} from \"../../core/canvas/session.js\";\nimport { getWorkspacePlatforms } from \"../../core/extensions/plugins/formats/platform-targets.js\";\nimport { isWorkspaceTrusted, trustWorkspace } from \"../../core/extensions/plugins/trust.js\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\nimport { createCanvasToolDefinitions } from \"../../core/tools/canvas.js\";\nimport { BorderedLoader } from \"../../modes/interactive/components/bordered-loader.js\";\n\nconst SUBCOMMANDS = [\"list\", \"open\", \"close\", \"reload\", \"rename\", \"remove\"] as const;\n\n/** How an open attempt ended. `custom()` resolves with exactly one of these. */\ntype OpenOutcome =\n\t| { kind: \"opened\"; instance: CanvasInstance }\n\t| { kind: \"failed\"; message: string }\n\t| { kind: \"cancelled\" };\n\nfunction describeInstance(instance: CanvasInstance): string {\n\tconst title = instance.title ?? instance.canvasId;\n\treturn `${instance.instanceId} ${title}${instance.url ? ` ${instance.url}` : \"\"}`;\n}\n\nfunction renderOverview(overview: CanvasOverview): string {\n\tconst lines: string[] = [];\n\tif (!overview.availability.available) {\n\t\tlines.push(`Canvases are unavailable: ${overview.availability.reason}`, \"\");\n\t}\n\tif (overview.listings.length === 0) {\n\t\tlines.push(\n\t\t\t\"No canvas extensions found in .agents/extensions, .github/extensions, ~/.copilot/extensions,\",\n\t\t\t\"or any installed plugin. Create one with /new-canvas <what it should do>, or install one with /plugin.\",\n\t\t);\n\t\treturn lines.join(\"\\n\");\n\t}\n\tfor (const listing of overview.listings) {\n\t\tconst name = listing.canvasId ? `${listing.extensionId}:${listing.canvasId}` : listing.extensionId;\n\t\tconst label = listing.displayName ? ` ${listing.displayName}` : \"\";\n\t\tif (listing.withheld === \"untrusted-workspace\") {\n\t\t\tlines.push(`${GLYPH} ${name}${label} [withheld: untrusted workspace]`);\n\t\t\tcontinue;\n\t\t}\n\t\tlines.push(`${GLYPH} ${name}${label} (${listing.scope})`);\n\t\tfor (const instance of listing.open) {\n\t\t\tlines.push(` open ${describeInstance(instance)}`);\n\t\t\t// What a canvas can do is otherwise visible only to the model, through\n\t\t\t// `list_canvas_capabilities` — so the person driving the session could not\n\t\t\t// see the surface they were being asked about. Only for open instances,\n\t\t\t// because actions come from running the code (§5.1).\n\t\t\tconst actions = overview.actionsByInstance.get(instance.instanceId) ?? [];\n\t\t\tif (actions.length > 0) lines.push(` actions ${actions.join(\", \")}`);\n\t\t}\n\t}\n\tif (overview.withheldCount > 0) {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`${overview.withheldCount} extension(s) came with this repository and are withheld. Run /plugin trust to allow this directory to run code it ships.`,\n\t\t);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Test seams, and only that.\n *\n * `/new-canvas` opens what it writes, so driving it without a terminal needs a\n * runtime that does not depend on hoocode having been built, and a home\n * directory that is not the developer's. Everything else this file does is\n * decided in `core/canvas/`, where it is testable without any of this.\n */\nexport interface CanvasSetupOverrides {\n\thomeDir?: string;\n\tresolveRuntime?: CanvasSessionOptions[\"resolveRuntime\"];\n}\n\nexport function setupCanvas(pi: ExtensionAPI, overrides?: CanvasSetupOverrides): void {\n\tlet session: CanvasSession | undefined;\n\tlet toolsRegistered = false;\n\t/**\n\t * Points at the most recent command's UI.\n\t *\n\t * A canvas keeps talking after the command that opened it has returned — logs,\n\t * stray stdout, a leaked-port warning — so the callbacks cannot close over one\n\t * invocation's `ctx`.\n\t */\n\tlet notify: (message: string, type?: \"info\" | \"warning\" | \"error\") => void = () => {};\n\n\tconst ensureSession = (ctx: ExtensionCommandContext): CanvasSession => {\n\t\tnotify = (message, type) => ctx.ui.notify(message, type);\n\t\tsession ??= new CanvasSession({\n\t\t\tcwd: ctx.cwd,\n\t\t\thomeDir: overrides?.homeDir ?? homedir(),\n\t\t\tagentDir: getAgentDir(),\n\t\t\tresolveRuntime: overrides?.resolveRuntime,\n\t\t\t// A canvas's own diagnostics are the user's business: a stray stdout line means\n\t\t\t// its author reached for console.log, and a possible leaked port is worth saying.\n\t\t\tonLog: (id, message) => notify(`[canvas ${id}] ${message}`, \"info\"),\n\t\t\tonStray: (id, line) => notify(`[canvas ${id}] non-protocol stdout (use session.log): ${line}`, \"warning\"),\n\t\t\tonDiagnostic: (id, message) => notify(`[canvas ${id}] ${message}`, \"warning\"),\n\t\t});\n\t\treturn session;\n\t};\n\n\tconst registerToolsOnce = (canvas: CanvasSession): void => {\n\t\tif (toolsRegistered) return;\n\t\tconst registry = canvas.registryOrUndefined();\n\t\tif (!registry) return;\n\t\ttoolsRegistered = true;\n\t\tfor (const definition of createCanvasToolDefinitions(registry)) pi.registerTool(definition);\n\t};\n\n\t/**\n\t * Open a canvas behind a cancellable loader.\n\t *\n\t * Shared by `/canvas open` and `/new-canvas`, which want identical behaviour:\n\t * opening forks a process and binds a port, so it can be slow and must be\n\t * interruptible. The loader's signal is what makes Esc mean something — it\n\t * reaches the registry's abandon path, which tells the extension to release a\n\t * port it may already have bound. Outside a terminal (--print, RPC) there is\n\t * nothing to draw and nothing to press, so the open simply runs.\n\t */\n\tconst openWithLoader = async (\n\t\tcanvas: CanvasSession,\n\t\tref: { extensionId: string; canvasId?: string },\n\t\tctx: ExtensionCommandContext,\n\t): Promise<OpenOutcome | undefined> =>\n\t\tctx.hasUI\n\t\t\t? await ctx.ui.custom<OpenOutcome | undefined>((tui, theme, _keybindings, done) => {\n\t\t\t\t\tconst loader = new BorderedLoader(tui, theme, `Opening ${ref.extensionId}…`);\n\t\t\t\t\tloader.onAbort = () => done({ kind: \"cancelled\" });\n\t\t\t\t\tvoid canvas\n\t\t\t\t\t\t.open(ref, { signal: loader.signal })\n\t\t\t\t\t\t.then((instance) => done({ kind: \"opened\", instance }))\n\t\t\t\t\t\t// Cancelling races: the signal rejects the pending call at the same moment\n\t\t\t\t\t\t// onAbort fires, and whichever lands first resolves `custom`. Deciding from\n\t\t\t\t\t\t// the signal rather than from who won means a cancel always reads as a\n\t\t\t\t\t\t// cancel instead of surfacing as an error.\n\t\t\t\t\t\t.catch((error: unknown) =>\n\t\t\t\t\t\t\tdone(\n\t\t\t\t\t\t\t\tloader.signal.aborted\n\t\t\t\t\t\t\t\t\t? { kind: \"cancelled\" }\n\t\t\t\t\t\t\t\t\t: { kind: \"failed\", message: error instanceof Error ? error.message : String(error) },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\treturn loader;\n\t\t\t\t})\n\t\t\t: await canvas\n\t\t\t\t\t.open(ref)\n\t\t\t\t\t.then((instance): OpenOutcome => ({ kind: \"opened\", instance }))\n\t\t\t\t\t.catch(\n\t\t\t\t\t\t(error: unknown): OpenOutcome => ({\n\t\t\t\t\t\t\tkind: \"failed\",\n\t\t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\n\tpi.registerCommand(\"canvas\", {\n\t\tdescription:\n\t\t\t\"Work with canvas extensions. /canvas list | open <extension>[:<canvas>] | reload [extension] | close <instanceId> | rename <extension> <new-name> | remove <extension>\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\tSUBCOMMANDS.filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst trimmed = args.trim();\n\t\t\tconst canvas = ensureSession(ctx);\n\n\t\t\tif (trimmed.length === 0 || trimmed === \"list\") {\n\t\t\t\tctx.ui.notify(renderOverview(await canvas.list()), \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"close\")) {\n\t\t\t\tconst instanceId = trimmed.slice(\"close\".length).trim();\n\t\t\t\tif (!instanceId) {\n\t\t\t\t\tctx.ui.notify(\"Usage: /canvas close <instanceId> (see /canvas list)\", \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst closed = await canvas.close(instanceId);\n\t\t\t\tif (!closed) ctx.ui.notify(`No open canvas instance \"${instanceId}\".`, \"warning\");\n\t\t\t\telse ctx.ui.notify(`Closed ${closed.canvasId} (${closed.instanceId}).`, \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"reload\")) {\n\t\t\t\t// A reload re-forks the extension, so it can take as long as an open and is\n\t\t\t\t// worth being able to abandon — but unlike an open there is nothing to\n\t\t\t\t// abandon *to*: the registry keeps the old child serving until the new one\n\t\t\t\t// answers, so a cancel here just stops waiting.\n\t\t\t\tconst requested = trimmed.slice(\"reload\".length).trim();\n\t\t\t\tconst running = canvas.runningExtensionIds();\n\t\t\t\tconst extensionId = requested || (running.length === 1 ? running[0] : undefined);\n\t\t\t\tif (!extensionId) {\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\trunning.length === 0\n\t\t\t\t\t\t\t? \"Nothing is open, so there is nothing to reload. Open a canvas first with /canvas open <extension>.\"\n\t\t\t\t\t\t\t: `Several extensions are open; name one: ${running.join(\", \")}.`,\n\t\t\t\t\t\t\"warning\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await canvas.reload(extensionId);\n\t\t\t\t\tconst lines = [`Reloaded ${result.extensionId} from disk.`];\n\t\t\t\t\t// Same reason the tool reports it: an edit to `actions: [...]` is\n\t\t\t\t\t// otherwise invisible, and a typo there fails by the action simply\n\t\t\t\t\t// not being there.\n\t\t\t\t\tconst { added, removed, changed } = result.actions;\n\t\t\t\t\tif (added.length > 0) lines.push(` + ${added.join(\", \")}`);\n\t\t\t\t\tif (removed.length > 0) lines.push(` - ${removed.join(\", \")}`);\n\t\t\t\t\tif (changed.length > 0) lines.push(` ~ ${changed.join(\", \")} (description or schema)`);\n\t\t\t\t\tfor (const instance of result.reopened) {\n\t\t\t\t\t\t// The url is the point of saying anything: the extension binds a new\n\t\t\t\t\t\t// port and mints a new token on every open, so the tab the person has\n\t\t\t\t\t\t// in front of them is now pointing at a closed port.\n\t\t\t\t\t\tlines.push(` ${describeInstance(instance)}`);\n\t\t\t\t\t}\n\t\t\t\t\tif (result.reopened.length > 0) lines.push(\"\", \"Open the new url(s) — the previous tab is dead.\");\n\t\t\t\t\tfor (const drop of result.dropped) {\n\t\t\t\t\t\tlines.push(` ${drop.canvasId} (${drop.instanceId}) did not come back: ${drop.reason}`);\n\t\t\t\t\t}\n\t\t\t\t\tctx.ui.notify(lines.join(\"\\n\"), result.dropped.length > 0 ? \"warning\" : \"info\");\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// The registry only swaps children once the new one is ready, so the\n\t\t\t\t\t// canvas the person is looking at survived this. Say so, or they will\n\t\t\t\t\t// think they just lost it.\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`Reloading ${extensionId} failed, so it is still running the code it was started with: ` +\n\t\t\t\t\t\t\t`${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\t\t\"error\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"rename\")) {\n\t\t\t\tconst [from, to, ...rest] = trimmed.slice(\"rename\".length).trim().split(/\\s+/).filter(Boolean);\n\t\t\t\tif (!from || !to || rest.length > 0) {\n\t\t\t\t\tctx.ui.notify(`Usage: /canvas rename <extension> <new-name> (see /canvas list)`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Renaming closes whatever the extension had open, because the directory\n\t\t\t\t// is about to move out from under it. Say so before doing it, not after.\n\t\t\t\tconst wasOpen = canvas.instances().filter((instance) => instance.extensionId === from);\n\t\t\t\tconst result = await canvas.rename(from, to);\n\t\t\t\tif (isCanvasRefusal(result)) {\n\t\t\t\t\tctx.ui.notify(`/canvas rename: ${result.detail}`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst lines = [`Renamed ${result.from} → ${result.to}.`, ` ${result.dir}`];\n\t\t\t\tif (result.rewrites.length > 0) {\n\t\t\t\t\tlines.push(\"\", \"Rewrote in extension.mjs:\");\n\t\t\t\t\tfor (const rewrite of result.rewrites) lines.push(` line ${rewrite.line}: ${rewrite.after.trim()}`);\n\t\t\t\t}\n\t\t\t\tif (result.leftovers.length > 0) {\n\t\t\t\t\t// Prose is not identity, so it is reported rather than edited — a rename\n\t\t\t\t\t// that silently rewrote a description would be worse than one that\n\t\t\t\t\t// admits what it left.\n\t\t\t\t\tlines.push(\n\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t`\"${result.from}\" still appears on line(s) ${result.leftovers.join(\", \")}; those look like prose, so they were left alone.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (wasOpen.length > 0) lines.push(\"\", `Closed ${wasOpen.length} open instance(s) to move the directory.`);\n\t\t\t\tlines.push(\"\", `Open it with /canvas open ${result.to}.`);\n\t\t\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"remove\")) {\n\t\t\t\tconst target = trimmed.slice(\"remove\".length).trim();\n\t\t\t\tif (!target) {\n\t\t\t\t\tctx.ui.notify(\"Usage: /canvas remove <extension> (see /canvas list)\", \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst known = canvas.knownExtensionIds();\n\t\t\t\tif (!known.includes(target)) {\n\t\t\t\t\tctx.ui.notify(`No canvas extension \"${target}\" (found: ${known.join(\", \") || \"none\"}).`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Deleting source is not undoable from here, so it is confirmed — and\n\t\t\t\t// outside a terminal there is nobody to ask, so it is refused rather than\n\t\t\t\t// assumed. `--print` and RPC should not be able to delete a directory\n\t\t\t\t// because a command happened to be piped in.\n\t\t\t\tif (!ctx.hasUI) {\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`/canvas remove needs to ask before deleting ${target}, and there is no interactive surface here. Delete the directory yourself, or run this in a terminal.`,\n\t\t\t\t\t\t\"warning\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst confirmed = await ctx.ui.confirm(\n\t\t\t\t\t`Delete canvas \"${target}\"?`,\n\t\t\t\t\t\"This deletes the extension directory and everything in it. It is not undoable from here.\",\n\t\t\t\t);\n\t\t\t\tif (!confirmed) {\n\t\t\t\t\tctx.ui.notify(`Left ${target} alone.`, \"info\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst openCount = canvas.instances().filter((instance) => instance.extensionId === target).length;\n\t\t\t\tconst result = await canvas.remove(target);\n\t\t\t\tif (isCanvasRefusal(result)) {\n\t\t\t\t\tctx.ui.notify(`/canvas remove: ${result.detail}`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t[\n\t\t\t\t\t\t`Removed ${result.id}.`,\n\t\t\t\t\t\t` ${result.dir}`,\n\t\t\t\t\t\topenCount > 0 ? `Closed ${openCount} open instance(s) first.` : \"\",\n\t\t\t\t\t]\n\t\t\t\t\t\t.filter((line) => line.length > 0)\n\t\t\t\t\t\t.join(\"\\n\"),\n\t\t\t\t\t\"info\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!trimmed.startsWith(\"open\")) {\n\t\t\t\tctx.ui.notify(`Unknown subcommand. Use ${SUBCOMMANDS.join(\", \")}.`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst ref = parseCanvasRef(trimmed.slice(\"open\".length));\n\t\t\tif (!ref) {\n\t\t\t\tctx.ui.notify(\"Usage: /canvas open <extension>[:<canvas>] (see /canvas list)\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst outcome = await openWithLoader(canvas, ref, ctx);\n\n\t\t\t// `custom` can also settle on its own when the overlay is dismissed, without\n\t\t\t// our `done` ever running — an escape that closes the surface leaves no\n\t\t\t// outcome. Treat that as the cancel it is rather than reading `.kind` off\n\t\t\t// undefined and failing silently.\n\t\t\tif (!outcome || outcome.kind === \"cancelled\") {\n\t\t\t\t// KNOWN ISSUE: this confirmation does not render when the cancel came from the\n\t\t\t\t// loader's own escape handling, though every effect of cancelling is correct\n\t\t\t\t// and verified (the open rejects, no instance is registered, and the extension\n\t\t\t\t// is told to close the instance it never finished opening). Ruled out: the\n\t\t\t\t// continuation does run and `ctx.ui.notify` works here — the failure path\n\t\t\t\t// through the same lines renders its error, and the canvas's own diagnostic\n\t\t\t\t// arrives moments later through this very function. Deferring a tick did not\n\t\t\t\t// help either. Left as an unexplained cosmetic gap rather than papered over\n\t\t\t\t// with a sleep; the loader disappearing is itself the signal.\n\t\t\t\tctx.ui.notify(\"Canvas open cancelled.\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (outcome.kind === \"failed\") {\n\t\t\t\tctx.ui.notify(outcome.message, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tregisterToolsOnce(canvas);\n\t\t\tctx.ui.notify(\n\t\t\t\t[\n\t\t\t\t\t`Opened ${outcome.instance.canvasId} (${outcome.instance.instanceId}).`,\n\t\t\t\t\toutcome.instance.url ? `Open in a browser: ${outcome.instance.url}` : \"\",\n\t\t\t\t\t\"The agent can now read and drive it; close it with /canvas close <instanceId>.\",\n\t\t\t\t]\n\t\t\t\t\t.filter((line) => line.length > 0)\n\t\t\t\t\t.join(\"\\n\"),\n\t\t\t\t\"info\",\n\t\t\t);\n\t\t},\n\t});\n\n\t// ── /new-canvas <name> | <description> ────────────────────────────────────\n\t// Authoring, in Copilot's `/create-canvas` shape: describe what you want, the\n\t// agent writes it, and it is already open while it does. Design:\n\t// `docs/canvas-extensions-design.md` §9 Phase 3, §13.\n\t//\n\t// Unlike the other `/new-*` scaffolds this one needs no /reload: canvases are\n\t// discovered when /canvas runs, not loaded at session start.\n\n\tpi.registerCommand(\"new-canvas\", {\n\t\tdescription:\n\t\t\t\"Create a canvas extension. Usage: /new-canvas <what it should do> | /new-canvas <name> | /new-canvas <name>: <what it should do>\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst request = parseCanvasRequest(args);\n\t\t\tif (typeof request === \"string\") {\n\t\t\t\tctx.ui.notify(`/new-canvas: ${request}`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// `--platform claude` is dropped rather than redirected: Claude has no\n\t\t\t// canvas convention, and silently writing into someone else's marker\n\t\t\t// directory would put an extension where that vendor will never look.\n\t\t\tconst requested = getWorkspacePlatforms() ?? [\"agents\"];\n\t\t\tconst targets = requested.filter((platform) => CANVAS_HOMES[platform] !== undefined);\n\t\t\tif (targets.length === 0) {\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`/new-canvas: no canvas home for platform \"${requested.join(\", \")}\". ` +\n\t\t\t\t\t\t\"Canvas extensions exist under .agents/extensions (agents) and .github/extensions (github) only.\",\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst { created, skipped } = scaffoldCanvas(ctx.cwd, request.name, targets);\n\n\t\t\t// A canvas lands in the working tree, which is where the trust gate looks\n\t\t\t// (`core/canvas/trust.ts`) — so without this the canvas you just asked for\n\t\t\t// is withheld the moment you try to open it, and refused with \"came with\n\t\t\t// this repository\", which is not true of a file created seconds ago.\n\t\t\t//\n\t\t\t// Granting is the same call `/plugin install --scope project` makes, for\n\t\t\t// the same stated reason: a person typing this command in this directory\n\t\t\t// is the human act workspace trust asks for. It is deliberately wider than\n\t\t\t// this one canvas — it also lets plugins already committed here run their\n\t\t\t// hooks and MCP servers — so it is said out loud and pointed at its\n\t\t\t// reverse, never done silently.\n\t\t\tlet trustNote = \"\";\n\t\t\tif (created.length > 0 && !isWorkspaceTrusted(ctx.cwd, getAgentDir())) {\n\t\t\t\ttrustWorkspace(ctx.cwd, getAgentDir());\n\t\t\t\ttrustNote =\n\t\t\t\t\t`Trusted this workspace so the canvas can run. Plugins committed here may now run hooks ` +\n\t\t\t\t\t`and MCP servers too; \\`/plugin untrust\\` reverses it.`;\n\t\t\t}\n\n\t\t\tif (created.length === 0) {\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t[\n\t\t\t\t\t\t\"Nothing created — these already exist:\",\n\t\t\t\t\t\t...skipped.map((file) => ` ${file}`),\n\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t`Open the existing one with /canvas open ${request.name}, or pick another name.`,\n\t\t\t\t\t].join(\"\\n\"),\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Open before saying anything. Copilot's `/create-canvas` puts the canvas\n\t\t\t// in front of the person and *then* builds it, and the order is the point:\n\t\t\t// a build brief that can name a live url and instance id is a different\n\t\t\t// instruction than one that cannot, and the person watches it change.\n\t\t\tconst canvas = ensureSession(ctx);\n\t\t\tconst outcome = await openWithLoader(canvas, { extensionId: request.name }, ctx);\n\t\t\tconst opened = outcome?.kind === \"opened\" ? outcome.instance : undefined;\n\t\t\tif (opened) registerToolsOnce(canvas);\n\n\t\t\tconst lines = [\"Canvas created:\", ...created.map((file) => ` ${file}`)];\n\t\t\tif (skipped.length > 0) lines.push(\"Skipped (already exist):\", ...skipped.map((file) => ` ${file}`));\n\t\t\tlines.push(\"\");\n\t\t\tif (opened) {\n\t\t\t\tlines.push(`Opened ${opened.canvasId} (${opened.instanceId}).`);\n\t\t\t\tif (opened.url) lines.push(`Open in a browser: ${opened.url}`);\n\t\t\t} else if (outcome?.kind === \"failed\") {\n\t\t\t\t// Not fatal: the file is written and discoverable, so say what broke and\n\t\t\t\t// leave them a way in rather than making it look like nothing happened.\n\t\t\t\tlines.push(\n\t\t\t\t\t`Created, but opening it failed: ${outcome.message}`,\n\t\t\t\t\t`Retry with /canvas open ${request.name}.`,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tlines.push(`Opening cancelled. Open it when you want with /canvas open ${request.name}.`);\n\t\t\t}\n\t\t\tif (trustNote) lines.push(\"\", trustNote);\n\n\t\t\tif (request.description) {\n\t\t\t\tlines.push(\n\t\t\t\t\t\"\",\n\t\t\t\t\t`Building it now from: \"${request.description}\"`,\n\t\t\t\t\t\"Steer it like any other turn, or interrupt to take over the file yourself.\",\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tlines.push(\"\", `Edit ${created[0]} and run /canvas reload ${request.name} to see each change.`);\n\t\t\t}\n\t\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\n\t\t\t// The half that makes this `/create-canvas` rather than a scaffold. Queued\n\t\t\t// as a follow-up so it lands on the next turn whether or not the agent is\n\t\t\t// mid-stream, and only when a description was given: `/new-canvas my-board`\n\t\t\t// still means \"give me the template\", and starting a build nobody asked for\n\t\t\t// would burn a turn and overwrite the file they meant to edit.\n\t\t\tif (request.description) {\n\t\t\t\tawait pi.sendUserMessage(\n\t\t\t\t\tcanvasBuildBrief(\n\t\t\t\t\t\trequest.name,\n\t\t\t\t\t\trequest.description,\n\t\t\t\t\t\tcreated[0] as string,\n\t\t\t\t\t\topened ? { instanceId: opened.instanceId, url: opened.url } : undefined,\n\t\t\t\t\t),\n\t\t\t\t\t{ deliverAs: \"followUp\" },\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t});\n\n\t// Teardown on shutdown (§6): a browser tab gives no close signal, so without this\n\t// every child and loopback port outlives the session. `session_shutdown` is where\n\t// loop.ts stops its scheduler, and it is synchronous, so the dispose is fired and\n\t// not awaited.\n\tpi.on(\"session_shutdown\", () => {\n\t\tconst closing = session;\n\t\tsession = undefined;\n\t\tvoid closing?.dispose();\n\t});\n}\n"]}
1
+ {"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/canvas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAaH,OAAO,EAGN,KAAK,oBAAoB,EAEzB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,gCAAgC,CAAC;AAwD5F;;;;;;;GAOG;AACH,MAAM,WAAW,oBAAoB;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,oBAAoB,CAAC,gBAAgB,CAAC,CAAC;CACxD;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAwapF","sourcesContent":["/**\n * `/canvas` — the interactive surface for canvas extensions.\n *\n * Design: `docs/canvas-extensions-design.md` §11. Deliberately thin: every decision\n * lives in `core/canvas/session.ts`, which is testable without a terminal, so this\n * file only renders and supplies an `AbortSignal`.\n *\n * The signal is the point of the loader. `BorderedLoader` already gives Esc-to-cancel\n * and exposes an `AbortSignal`, and `registry.open` accepts one — so a person's Esc\n * reaches the abandon path (§11.6) and the extension is told to release the port it\n * may already have bound, rather than the spinner merely disappearing.\n *\n * `/new-canvas` is registered here rather than beside `/new-skill` and friends\n * because it is not a file-writing command any more: it opens what it scaffolds\n * and hands the agent a brief to build it, which needs this file's session and\n * `pi.sendUserMessage`. Its decisions live in `core/canvas/scaffold.ts`.\n *\n * The agent tools register on the first successful open and stay for the session:\n * `registerTool` has no counterpart to remove a tool. So a session that never opens a\n * canvas pays nothing for them, which is the case that matters (§11.5); after the\n * first open they cost ~235 tokens and answer honestly when nothing is open.\n */\n\nimport { homedir } from \"node:os\";\nimport { getAgentDir } from \"../../config.js\";\nimport { CATEGORY_GLYPH } from \"../../core/brand.js\";\nimport { canvasDesignGuidePath } from \"../../core/builtin-skills.js\";\n\n/** Canvas extensions are extensions, so they wear the extension glyph. */\nconst GLYPH = CATEGORY_GLYPH.extensions;\n\nimport { isCanvasRefusal } from \"../../core/canvas/lifecycle.js\";\nimport type { CanvasInstance } from \"../../core/canvas/registry.js\";\nimport { CANVAS_HOMES, canvasBuildBrief, parseCanvasRequest, scaffoldCanvas } from \"../../core/canvas/scaffold.js\";\nimport {\n\ttype CanvasOverview,\n\tCanvasSession,\n\ttype CanvasSessionOptions,\n\tparseCanvasRef,\n} from \"../../core/canvas/session.js\";\nimport { getWorkspacePlatforms } from \"../../core/extensions/plugins/formats/platform-targets.js\";\nimport { isWorkspaceTrusted, trustWorkspace } from \"../../core/extensions/plugins/trust.js\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\nimport { createCanvasToolDefinitions } from \"../../core/tools/canvas.js\";\nimport { BorderedLoader } from \"../../modes/interactive/components/bordered-loader.js\";\n\nconst SUBCOMMANDS = [\"list\", \"open\", \"close\", \"reload\", \"rename\", \"remove\"] as const;\n\n/** How an open attempt ended. `custom()` resolves with exactly one of these. */\ntype OpenOutcome =\n\t| { kind: \"opened\"; instance: CanvasInstance }\n\t| { kind: \"failed\"; message: string }\n\t| { kind: \"cancelled\" };\n\nfunction describeInstance(instance: CanvasInstance): string {\n\tconst title = instance.title ?? instance.canvasId;\n\treturn `${instance.instanceId} ${title}${instance.url ? ` ${instance.url}` : \"\"}`;\n}\n\nfunction renderOverview(overview: CanvasOverview): string {\n\tconst lines: string[] = [];\n\tif (!overview.availability.available) {\n\t\tlines.push(`Canvases are unavailable: ${overview.availability.reason}`, \"\");\n\t}\n\tif (overview.listings.length === 0) {\n\t\tlines.push(\n\t\t\t\"No canvas extensions found in .agents/extensions, .github/extensions, ~/.copilot/extensions,\",\n\t\t\t\"or any installed plugin. Create one with /new-canvas <what it should do>, or install one with /plugin.\",\n\t\t);\n\t\treturn lines.join(\"\\n\");\n\t}\n\tfor (const listing of overview.listings) {\n\t\tconst name = listing.canvasId ? `${listing.extensionId}:${listing.canvasId}` : listing.extensionId;\n\t\tconst label = listing.displayName ? ` ${listing.displayName}` : \"\";\n\t\tif (listing.withheld === \"untrusted-workspace\") {\n\t\t\tlines.push(`${GLYPH} ${name}${label} [withheld: untrusted workspace]`);\n\t\t\tcontinue;\n\t\t}\n\t\tlines.push(`${GLYPH} ${name}${label} (${listing.scope})`);\n\t\tfor (const instance of listing.open) {\n\t\t\tlines.push(` open ${describeInstance(instance)}`);\n\t\t\t// What a canvas can do is otherwise visible only to the model, through\n\t\t\t// `list_canvas_capabilities` — so the person driving the session could not\n\t\t\t// see the surface they were being asked about. Only for open instances,\n\t\t\t// because actions come from running the code (§5.1).\n\t\t\tconst actions = overview.actionsByInstance.get(instance.instanceId) ?? [];\n\t\t\tif (actions.length > 0) lines.push(` actions ${actions.join(\", \")}`);\n\t\t}\n\t}\n\tif (overview.withheldCount > 0) {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`${overview.withheldCount} extension(s) came with this repository and are withheld. Run /plugin trust to allow this directory to run code it ships.`,\n\t\t);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Test seams, and only that.\n *\n * `/new-canvas` opens what it writes, so driving it without a terminal needs a\n * runtime that does not depend on hoocode having been built, and a home\n * directory that is not the developer's. Everything else this file does is\n * decided in `core/canvas/`, where it is testable without any of this.\n */\nexport interface CanvasSetupOverrides {\n\thomeDir?: string;\n\tresolveRuntime?: CanvasSessionOptions[\"resolveRuntime\"];\n}\n\nexport function setupCanvas(pi: ExtensionAPI, overrides?: CanvasSetupOverrides): void {\n\tlet session: CanvasSession | undefined;\n\tlet toolsRegistered = false;\n\t/**\n\t * Points at the most recent command's UI.\n\t *\n\t * A canvas keeps talking after the command that opened it has returned — logs,\n\t * stray stdout, a leaked-port warning — so the callbacks cannot close over one\n\t * invocation's `ctx`.\n\t */\n\tlet notify: (message: string, type?: \"info\" | \"warning\" | \"error\") => void = () => {};\n\n\tconst ensureSession = (ctx: ExtensionCommandContext): CanvasSession => {\n\t\tnotify = (message, type) => ctx.ui.notify(message, type);\n\t\tsession ??= new CanvasSession({\n\t\t\tcwd: ctx.cwd,\n\t\t\thomeDir: overrides?.homeDir ?? homedir(),\n\t\t\tagentDir: getAgentDir(),\n\t\t\tresolveRuntime: overrides?.resolveRuntime,\n\t\t\t// A canvas's own diagnostics are the user's business: a stray stdout line means\n\t\t\t// its author reached for console.log, and a possible leaked port is worth saying.\n\t\t\tonLog: (id, message) => notify(`[canvas ${id}] ${message}`, \"info\"),\n\t\t\tonStray: (id, line) => notify(`[canvas ${id}] non-protocol stdout (use session.log): ${line}`, \"warning\"),\n\t\t\tonDiagnostic: (id, message) => notify(`[canvas ${id}] ${message}`, \"warning\"),\n\t\t});\n\t\treturn session;\n\t};\n\n\tconst registerToolsOnce = (canvas: CanvasSession): void => {\n\t\tif (toolsRegistered) return;\n\t\tconst registry = canvas.registryOrUndefined();\n\t\tif (!registry) return;\n\t\ttoolsRegistered = true;\n\t\tfor (const definition of createCanvasToolDefinitions(registry)) pi.registerTool(definition);\n\t};\n\n\t/**\n\t * Open a canvas behind a cancellable loader.\n\t *\n\t * Shared by `/canvas open` and `/new-canvas`, which want identical behaviour:\n\t * opening forks a process and binds a port, so it can be slow and must be\n\t * interruptible. The loader's signal is what makes Esc mean something — it\n\t * reaches the registry's abandon path, which tells the extension to release a\n\t * port it may already have bound. Outside a terminal (--print, RPC) there is\n\t * nothing to draw and nothing to press, so the open simply runs.\n\t */\n\tconst openWithLoader = async (\n\t\tcanvas: CanvasSession,\n\t\tref: { extensionId: string; canvasId?: string },\n\t\tctx: ExtensionCommandContext,\n\t): Promise<OpenOutcome | undefined> =>\n\t\tctx.hasUI\n\t\t\t? await ctx.ui.custom<OpenOutcome | undefined>((tui, theme, _keybindings, done) => {\n\t\t\t\t\tconst loader = new BorderedLoader(tui, theme, `Opening ${ref.extensionId}…`);\n\t\t\t\t\tloader.onAbort = () => done({ kind: \"cancelled\" });\n\t\t\t\t\tvoid canvas\n\t\t\t\t\t\t.open(ref, { signal: loader.signal })\n\t\t\t\t\t\t.then((instance) => done({ kind: \"opened\", instance }))\n\t\t\t\t\t\t// Cancelling races: the signal rejects the pending call at the same moment\n\t\t\t\t\t\t// onAbort fires, and whichever lands first resolves `custom`. Deciding from\n\t\t\t\t\t\t// the signal rather than from who won means a cancel always reads as a\n\t\t\t\t\t\t// cancel instead of surfacing as an error.\n\t\t\t\t\t\t.catch((error: unknown) =>\n\t\t\t\t\t\t\tdone(\n\t\t\t\t\t\t\t\tloader.signal.aborted\n\t\t\t\t\t\t\t\t\t? { kind: \"cancelled\" }\n\t\t\t\t\t\t\t\t\t: { kind: \"failed\", message: error instanceof Error ? error.message : String(error) },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\treturn loader;\n\t\t\t\t})\n\t\t\t: await canvas\n\t\t\t\t\t.open(ref)\n\t\t\t\t\t.then((instance): OpenOutcome => ({ kind: \"opened\", instance }))\n\t\t\t\t\t.catch(\n\t\t\t\t\t\t(error: unknown): OpenOutcome => ({\n\t\t\t\t\t\t\tkind: \"failed\",\n\t\t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\n\tpi.registerCommand(\"canvas\", {\n\t\tdescription:\n\t\t\t\"Work with canvas extensions. /canvas list | open <extension>[:<canvas>] | reload [extension] | close <instanceId> | rename <extension> <new-name> | remove <extension>\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\tSUBCOMMANDS.filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst trimmed = args.trim();\n\t\t\tconst canvas = ensureSession(ctx);\n\n\t\t\tif (trimmed.length === 0 || trimmed === \"list\") {\n\t\t\t\tctx.ui.notify(renderOverview(await canvas.list()), \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"close\")) {\n\t\t\t\tconst instanceId = trimmed.slice(\"close\".length).trim();\n\t\t\t\tif (!instanceId) {\n\t\t\t\t\tctx.ui.notify(\"Usage: /canvas close <instanceId> (see /canvas list)\", \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst closed = await canvas.close(instanceId);\n\t\t\t\tif (!closed) ctx.ui.notify(`No open canvas instance \"${instanceId}\".`, \"warning\");\n\t\t\t\telse ctx.ui.notify(`Closed ${closed.canvasId} (${closed.instanceId}).`, \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"reload\")) {\n\t\t\t\t// A reload re-forks the extension, so it can take as long as an open and is\n\t\t\t\t// worth being able to abandon — but unlike an open there is nothing to\n\t\t\t\t// abandon *to*: the registry keeps the old child serving until the new one\n\t\t\t\t// answers, so a cancel here just stops waiting.\n\t\t\t\tconst requested = trimmed.slice(\"reload\".length).trim();\n\t\t\t\tconst running = canvas.runningExtensionIds();\n\t\t\t\tconst extensionId = requested || (running.length === 1 ? running[0] : undefined);\n\t\t\t\tif (!extensionId) {\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\trunning.length === 0\n\t\t\t\t\t\t\t? \"Nothing is open, so there is nothing to reload. Open a canvas first with /canvas open <extension>.\"\n\t\t\t\t\t\t\t: `Several extensions are open; name one: ${running.join(\", \")}.`,\n\t\t\t\t\t\t\"warning\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await canvas.reload(extensionId);\n\t\t\t\t\tconst lines = [`Reloaded ${result.extensionId} from disk.`];\n\t\t\t\t\t// Same reason the tool reports it: an edit to `actions: [...]` is\n\t\t\t\t\t// otherwise invisible, and a typo there fails by the action simply\n\t\t\t\t\t// not being there.\n\t\t\t\t\tconst { added, removed, changed } = result.actions;\n\t\t\t\t\tif (added.length > 0) lines.push(` + ${added.join(\", \")}`);\n\t\t\t\t\tif (removed.length > 0) lines.push(` - ${removed.join(\", \")}`);\n\t\t\t\t\tif (changed.length > 0) lines.push(` ~ ${changed.join(\", \")} (description or schema)`);\n\t\t\t\t\tfor (const instance of result.reopened) {\n\t\t\t\t\t\t// The url is the point of saying anything: the extension binds a new\n\t\t\t\t\t\t// port and mints a new token on every open, so the tab the person has\n\t\t\t\t\t\t// in front of them is now pointing at a closed port.\n\t\t\t\t\t\tlines.push(` ${describeInstance(instance)}`);\n\t\t\t\t\t}\n\t\t\t\t\tif (result.reopened.length > 0) lines.push(\"\", \"Open the new url(s) — the previous tab is dead.\");\n\t\t\t\t\tfor (const drop of result.dropped) {\n\t\t\t\t\t\tlines.push(` ${drop.canvasId} (${drop.instanceId}) did not come back: ${drop.reason}`);\n\t\t\t\t\t}\n\t\t\t\t\tctx.ui.notify(lines.join(\"\\n\"), result.dropped.length > 0 ? \"warning\" : \"info\");\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// The registry only swaps children once the new one is ready, so the\n\t\t\t\t\t// canvas the person is looking at survived this. Say so, or they will\n\t\t\t\t\t// think they just lost it.\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`Reloading ${extensionId} failed, so it is still running the code it was started with: ` +\n\t\t\t\t\t\t\t`${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\t\t\"error\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"rename\")) {\n\t\t\t\tconst [from, to, ...rest] = trimmed.slice(\"rename\".length).trim().split(/\\s+/).filter(Boolean);\n\t\t\t\tif (!from || !to || rest.length > 0) {\n\t\t\t\t\tctx.ui.notify(`Usage: /canvas rename <extension> <new-name> (see /canvas list)`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Renaming closes whatever the extension had open, because the directory\n\t\t\t\t// is about to move out from under it. Say so before doing it, not after.\n\t\t\t\tconst wasOpen = canvas.instances().filter((instance) => instance.extensionId === from);\n\t\t\t\tconst result = await canvas.rename(from, to);\n\t\t\t\tif (isCanvasRefusal(result)) {\n\t\t\t\t\tctx.ui.notify(`/canvas rename: ${result.detail}`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst lines = [`Renamed ${result.from} → ${result.to}.`, ` ${result.dir}`];\n\t\t\t\tif (result.rewrites.length > 0) {\n\t\t\t\t\tlines.push(\"\", \"Rewrote in extension.mjs:\");\n\t\t\t\t\tfor (const rewrite of result.rewrites) lines.push(` line ${rewrite.line}: ${rewrite.after.trim()}`);\n\t\t\t\t}\n\t\t\t\tif (result.leftovers.length > 0) {\n\t\t\t\t\t// Prose is not identity, so it is reported rather than edited — a rename\n\t\t\t\t\t// that silently rewrote a description would be worse than one that\n\t\t\t\t\t// admits what it left.\n\t\t\t\t\tlines.push(\n\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t`\"${result.from}\" still appears on line(s) ${result.leftovers.join(\", \")}; those look like prose, so they were left alone.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (wasOpen.length > 0) lines.push(\"\", `Closed ${wasOpen.length} open instance(s) to move the directory.`);\n\t\t\t\tlines.push(\"\", `Open it with /canvas open ${result.to}.`);\n\t\t\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"remove\")) {\n\t\t\t\tconst target = trimmed.slice(\"remove\".length).trim();\n\t\t\t\tif (!target) {\n\t\t\t\t\tctx.ui.notify(\"Usage: /canvas remove <extension> (see /canvas list)\", \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst known = canvas.knownExtensionIds();\n\t\t\t\tif (!known.includes(target)) {\n\t\t\t\t\tctx.ui.notify(`No canvas extension \"${target}\" (found: ${known.join(\", \") || \"none\"}).`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Deleting source is not undoable from here, so it is confirmed — and\n\t\t\t\t// outside a terminal there is nobody to ask, so it is refused rather than\n\t\t\t\t// assumed. `--print` and RPC should not be able to delete a directory\n\t\t\t\t// because a command happened to be piped in.\n\t\t\t\tif (!ctx.hasUI) {\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`/canvas remove needs to ask before deleting ${target}, and there is no interactive surface here. Delete the directory yourself, or run this in a terminal.`,\n\t\t\t\t\t\t\"warning\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst confirmed = await ctx.ui.confirm(\n\t\t\t\t\t`Delete canvas \"${target}\"?`,\n\t\t\t\t\t\"This deletes the extension directory and everything in it. It is not undoable from here.\",\n\t\t\t\t);\n\t\t\t\tif (!confirmed) {\n\t\t\t\t\tctx.ui.notify(`Left ${target} alone.`, \"info\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst openCount = canvas.instances().filter((instance) => instance.extensionId === target).length;\n\t\t\t\tconst result = await canvas.remove(target);\n\t\t\t\tif (isCanvasRefusal(result)) {\n\t\t\t\t\tctx.ui.notify(`/canvas remove: ${result.detail}`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t[\n\t\t\t\t\t\t`Removed ${result.id}.`,\n\t\t\t\t\t\t` ${result.dir}`,\n\t\t\t\t\t\topenCount > 0 ? `Closed ${openCount} open instance(s) first.` : \"\",\n\t\t\t\t\t]\n\t\t\t\t\t\t.filter((line) => line.length > 0)\n\t\t\t\t\t\t.join(\"\\n\"),\n\t\t\t\t\t\"info\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!trimmed.startsWith(\"open\")) {\n\t\t\t\tctx.ui.notify(`Unknown subcommand. Use ${SUBCOMMANDS.join(\", \")}.`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst ref = parseCanvasRef(trimmed.slice(\"open\".length));\n\t\t\tif (!ref) {\n\t\t\t\tctx.ui.notify(\"Usage: /canvas open <extension>[:<canvas>] (see /canvas list)\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst outcome = await openWithLoader(canvas, ref, ctx);\n\n\t\t\t// `custom` can also settle on its own when the overlay is dismissed, without\n\t\t\t// our `done` ever running — an escape that closes the surface leaves no\n\t\t\t// outcome. Treat that as the cancel it is rather than reading `.kind` off\n\t\t\t// undefined and failing silently.\n\t\t\tif (!outcome || outcome.kind === \"cancelled\") {\n\t\t\t\t// KNOWN ISSUE: this confirmation does not render when the cancel came from the\n\t\t\t\t// loader's own escape handling, though every effect of cancelling is correct\n\t\t\t\t// and verified (the open rejects, no instance is registered, and the extension\n\t\t\t\t// is told to close the instance it never finished opening). Ruled out: the\n\t\t\t\t// continuation does run and `ctx.ui.notify` works here — the failure path\n\t\t\t\t// through the same lines renders its error, and the canvas's own diagnostic\n\t\t\t\t// arrives moments later through this very function. Deferring a tick did not\n\t\t\t\t// help either. Left as an unexplained cosmetic gap rather than papered over\n\t\t\t\t// with a sleep; the loader disappearing is itself the signal.\n\t\t\t\tctx.ui.notify(\"Canvas open cancelled.\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (outcome.kind === \"failed\") {\n\t\t\t\tctx.ui.notify(outcome.message, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tregisterToolsOnce(canvas);\n\t\t\tctx.ui.notify(\n\t\t\t\t[\n\t\t\t\t\t`Opened ${outcome.instance.canvasId} (${outcome.instance.instanceId}).`,\n\t\t\t\t\toutcome.instance.url ? `Open in a browser: ${outcome.instance.url}` : \"\",\n\t\t\t\t\t\"The agent can now read and drive it; close it with /canvas close <instanceId>.\",\n\t\t\t\t]\n\t\t\t\t\t.filter((line) => line.length > 0)\n\t\t\t\t\t.join(\"\\n\"),\n\t\t\t\t\"info\",\n\t\t\t);\n\t\t},\n\t});\n\n\t// ── /new-canvas <name> | <description> ────────────────────────────────────\n\t// Authoring, in Copilot's `/create-canvas` shape: describe what you want, the\n\t// agent writes it, and it is already open while it does. Design:\n\t// `docs/canvas-extensions-design.md` §9 Phase 3, §13.\n\t//\n\t// Unlike the other `/new-*` scaffolds this one needs no /reload: canvases are\n\t// discovered when /canvas runs, not loaded at session start.\n\n\tpi.registerCommand(\"new-canvas\", {\n\t\tdescription:\n\t\t\t\"Create a canvas extension. Usage: /new-canvas <what it should do> | /new-canvas <name> | /new-canvas <name>: <what it should do>\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst request = parseCanvasRequest(args);\n\t\t\tif (typeof request === \"string\") {\n\t\t\t\tctx.ui.notify(`/new-canvas: ${request}`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// `--platform claude` is dropped rather than redirected: Claude has no\n\t\t\t// canvas convention, and silently writing into someone else's marker\n\t\t\t// directory would put an extension where that vendor will never look.\n\t\t\tconst requested = getWorkspacePlatforms() ?? [\"agents\"];\n\t\t\tconst targets = requested.filter((platform) => CANVAS_HOMES[platform] !== undefined);\n\t\t\tif (targets.length === 0) {\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`/new-canvas: no canvas home for platform \"${requested.join(\", \")}\". ` +\n\t\t\t\t\t\t\"Canvas extensions exist under .agents/extensions (agents) and .github/extensions (github) only.\",\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst { created, skipped } = scaffoldCanvas(ctx.cwd, request.name, targets);\n\n\t\t\t// A canvas lands in the working tree, which is where the trust gate looks\n\t\t\t// (`core/canvas/trust.ts`) — so without this the canvas you just asked for\n\t\t\t// is withheld the moment you try to open it, and refused with \"came with\n\t\t\t// this repository\", which is not true of a file created seconds ago.\n\t\t\t//\n\t\t\t// Granting is the same call `/plugin install --scope project` makes, for\n\t\t\t// the same stated reason: a person typing this command in this directory\n\t\t\t// is the human act workspace trust asks for. It is deliberately wider than\n\t\t\t// this one canvas — it also lets plugins already committed here run their\n\t\t\t// hooks and MCP servers — so it is said out loud and pointed at its\n\t\t\t// reverse, never done silently.\n\t\t\tlet trustNote = \"\";\n\t\t\tif (created.length > 0 && !isWorkspaceTrusted(ctx.cwd, getAgentDir())) {\n\t\t\t\ttrustWorkspace(ctx.cwd, getAgentDir());\n\t\t\t\ttrustNote =\n\t\t\t\t\t`Trusted this workspace so the canvas can run. Plugins committed here may now run hooks ` +\n\t\t\t\t\t`and MCP servers too; \\`/plugin untrust\\` reverses it.`;\n\t\t\t}\n\n\t\t\tif (created.length === 0) {\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t[\n\t\t\t\t\t\t\"Nothing created — these already exist:\",\n\t\t\t\t\t\t...skipped.map((file) => ` ${file}`),\n\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t`Open the existing one with /canvas open ${request.name}, or pick another name.`,\n\t\t\t\t\t].join(\"\\n\"),\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Open before saying anything. Copilot's `/create-canvas` puts the canvas\n\t\t\t// in front of the person and *then* builds it, and the order is the point:\n\t\t\t// a build brief that can name a live url and instance id is a different\n\t\t\t// instruction than one that cannot, and the person watches it change.\n\t\t\tconst canvas = ensureSession(ctx);\n\t\t\tconst outcome = await openWithLoader(canvas, { extensionId: request.name }, ctx);\n\t\t\tconst opened = outcome?.kind === \"opened\" ? outcome.instance : undefined;\n\t\t\tif (opened) registerToolsOnce(canvas);\n\n\t\t\tconst lines = [\"Canvas created:\", ...created.map((file) => ` ${file}`)];\n\t\t\tif (skipped.length > 0) lines.push(\"Skipped (already exist):\", ...skipped.map((file) => ` ${file}`));\n\t\t\tlines.push(\"\");\n\t\t\tif (opened) {\n\t\t\t\tlines.push(`Opened ${opened.canvasId} (${opened.instanceId}).`);\n\t\t\t\tif (opened.url) lines.push(`Open in a browser: ${opened.url}`);\n\t\t\t} else if (outcome?.kind === \"failed\") {\n\t\t\t\t// Not fatal: the file is written and discoverable, so say what broke and\n\t\t\t\t// leave them a way in rather than making it look like nothing happened.\n\t\t\t\tlines.push(\n\t\t\t\t\t`Created, but opening it failed: ${outcome.message}`,\n\t\t\t\t\t`Retry with /canvas open ${request.name}.`,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tlines.push(`Opening cancelled. Open it when you want with /canvas open ${request.name}.`);\n\t\t\t}\n\t\t\tif (trustNote) lines.push(\"\", trustNote);\n\n\t\t\tif (request.description) {\n\t\t\t\tlines.push(\n\t\t\t\t\t\"\",\n\t\t\t\t\t`Building it now from: \"${request.description}\"`,\n\t\t\t\t\t\"Steer it like any other turn, or interrupt to take over the file yourself.\",\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tlines.push(\"\", `Edit ${created[0]} and run /canvas reload ${request.name} to see each change.`);\n\t\t\t}\n\t\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\n\t\t\t// The half that makes this `/create-canvas` rather than a scaffold. Queued\n\t\t\t// as a follow-up so it lands on the next turn whether or not the agent is\n\t\t\t// mid-stream, and only when a description was given: `/new-canvas my-board`\n\t\t\t// still means \"give me the template\", and starting a build nobody asked for\n\t\t\t// would burn a turn and overwrite the file they meant to edit.\n\t\t\tif (request.description) {\n\t\t\t\tawait pi.sendUserMessage(\n\t\t\t\t\tcanvasBuildBrief(\n\t\t\t\t\t\trequest.name,\n\t\t\t\t\t\trequest.description,\n\t\t\t\t\t\tcreated[0] as string,\n\t\t\t\t\t\topened ? { instanceId: opened.instanceId, url: opened.url } : undefined,\n\t\t\t\t\t\tcanvasDesignGuidePath(),\n\t\t\t\t\t),\n\t\t\t\t\t{ deliverAs: \"followUp\" },\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t});\n\n\t// Teardown on shutdown (§6): a browser tab gives no close signal, so without this\n\t// every child and loopback port outlives the session. `session_shutdown` is where\n\t// loop.ts stops its scheduler, and it is synchronous, so the dispose is fired and\n\t// not awaited.\n\tpi.on(\"session_shutdown\", () => {\n\t\tconst closing = session;\n\t\tsession = undefined;\n\t\tvoid closing?.dispose();\n\t});\n}\n"]}
@@ -23,6 +23,7 @@
23
23
  import { homedir } from "node:os";
24
24
  import { getAgentDir } from "../../config.js";
25
25
  import { CATEGORY_GLYPH } from "../../core/brand.js";
26
+ import { canvasDesignGuidePath } from "../../core/builtin-skills.js";
26
27
  /** Canvas extensions are extensions, so they wear the extension glyph. */
27
28
  const GLYPH = CATEGORY_GLYPH.extensions;
28
29
  import { isCanvasRefusal } from "../../core/canvas/lifecycle.js";
@@ -418,7 +419,7 @@ export function setupCanvas(pi, overrides) {
418
419
  // still means "give me the template", and starting a build nobody asked for
419
420
  // would burn a turn and overwrite the file they meant to edit.
420
421
  if (request.description) {
421
- await pi.sendUserMessage(canvasBuildBrief(request.name, request.description, created[0], opened ? { instanceId: opened.instanceId, url: opened.url } : undefined), { deliverAs: "followUp" });
422
+ await pi.sendUserMessage(canvasBuildBrief(request.name, request.description, created[0], opened ? { instanceId: opened.instanceId, url: opened.url } : undefined, canvasDesignGuidePath()), { deliverAs: "followUp" });
422
423
  }
423
424
  },
424
425
  });