# Changelog ## 5.4.3 - 2026-08-17 - The CLI now forces Commander's Node argument layout when it runs through an Electron executable with `ELECTRON_RUN_AS_NODE=1`. This keeps the script path out of the default search command's positional arguments, so the dsh plugin can use `web_search`, `x_search`, and `read_page` from Electron desktop hosts. ## 5.4.2 - 2026-08-15 - The dsh plugin now launches its bundled CLI correctly from Electron desktop hosts on Windows. Electron makes `process.execPath` point at the desktop executable, so the child launch explicitly enables `ELECTRON_RUN_AS_NODE=1` when `process.versions.electron` is present. This prevents `dist/main.js` and the search flags from being handed back to `DeepSeek Harness.exe` as application arguments. A regression test simulates the Electron host boundary and fails unless the CLI child receives Node mode. ## 5.4.1 - 2026-08-14 - The positioning is corrected everywhere it was wrong: modsearch is for models **without native web access**, not for "text-only LLMs" (text-only describes missing vision, which is modlens's problem; a fully multimodal model behind a provider that ships no search tool needs modsearch just the same). Reworded: both READMEs' taglines and demo captions, the npm package description, the skill description, the CLI help line, the engine prompts, the GitHub About and topics, and the four image assets that carried the old subtitle (banner, social preview, both flow diagrams). - Every user-facing doc now ships a Chinese edition (`*.zh-CN.md`): the configuration guide, CLI manual, runtime reference, output contract, harness setup, troubleshooting, and security. Each pair carries a language switcher under its title, the Chinese README links to the Chinese editions throughout, and a new drift test keeps the translated output-contract envelopes structurally identical to the English ones. The engine roster also moved onto the README's first screen: a hero line names every engine, and the "Supported engines" table now sits above Installation. Fixed a stale root-relative link in the CLI manual along the way. ## 5.4.0 - 2026-08-14 - The HTTP engines (tavily, exa, firecrawl) accept a custom endpoint (#12): `modsearch config set tavily.baseURL `, or `TAVILY_BASE_URL` / `EXA_BASE_URL` / `FIRECRAWL_BASE_URL` in the environment, points the engine at a compatible third-party gateway, proxy, or self-hosted deployment. The engine appends its documented path to the base (`/search`, `/v2/search`, `/v2/scrape`), `config set` refuses a value that is not a full http(s) URL, an empty value unsets the override, and the API key goes to whatever host the base names, which is the point and the stated trust decision. - Both READMEs gain a "Supported engines" section: every engine with its roles, free tier, and the one command that turns it on, plus the env-var alternatives and the custom-endpoint pointer. The engines were always supported; the README never said how to switch them on without digging into the configuration reference. ## 5.3.0 - 2026-08-14 - ModSearch is now a DeepSeek Harness (dsh) plugin. The package doubles as a dsh bundle: `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modsearch@latest` mounts `dsh/index.js`, which plugs in three ways. The engine chain registers as the web seam's search provider and the bundle patch repoints the seam at it (`searchProvider: modsearch`), so dsh's native `web_search` tool and its citation cards run keyless through agy. `x_search` registers as a tool for the corpus dsh has no seam for, with a web stand-in answer marked degraded in the canonical value, never silent. `read_page` registers as a focused single-URL reader (summary, extracted content, links, uncertainty, optional answer focus); dsh ships its own `web_fetch` disabled for SSRF reasons, and modsearch's fetch blocks private-network targets by default with no override exposed through the tool. The plugin is plain JS with no dsh package imports, spawns the CLI from `dist/main.js` inside the same package, and `src/dshPlugin.test.ts` holds the shipped schema copies in lockstep with `src/schema.ts` and tests the behavior against a fake CLI. ## 5.2.0 - 2026-08-07 Fixes from a deep acceptance review (external audit, reproduced and verified here), plus keyless Firecrawl search. - Firecrawl search now works with no key at all. Firecrawl's REST API accepts unauthenticated calls against a shared free allowance (1,000 credits/month, verified against the live endpoint), so `firecrawl` closes every search chain as a zero-setup floor: a bare machine with nothing installed and nothing configured can search. Requests without a key send no Authorization header; a configured key still gets its own quota. Fetch stays keyed on purpose, since pages should not flow through a third-party cloud unless the user opted in by configuring it, and `doctor` explains the split per role. - BREAKING (behavior): the cloud crawler never receives a target that is, or resolves to, a private or reserved address. `--allow-private-network` used to carry through to firecrawl, sending a reserved-resolving hostname with its full path and query to the cloud when the switch was on. The switch now governs the local fetcher only, because a VPN fake-ip and a real internal name cannot be told apart, and the run falls through to the local engine, which the switch does let reach the target. - Cooldown clear now always reaches the disk. It used to be guarded by the run's in-memory snapshot, so a run that started before another process recorded a cooldown would "clear" it while the file kept it, silently undoing an explicit clear. And the store, defined as a pure cache, could break a search: a failed write (read-only dir, full disk, Windows rename lock) aborted failover mid-loop, and a failed clear after a success threw away a result already won. Cache I/O failures now degrade into a `warnings` entry while the run continues, unchanged writes are skipped entirely, and `modsearch state clear` exits non-zero when the delete fails instead of printing success. - Firecrawl requests explicitly send `storeInCache: false` and `skipTlsVerification: false`. The API defaults both to true, so a fetch that already forced `maxAge: 0` still wrote the scraped page into Firecrawl's index and skipped certificate checks. The timeout is clamped to the documented 1000-300000 ms contract at both ends. - Eval outcomes are honest. A missing key and a spent quota both counted as PASS, so a broken provider kept the eval green. Checks now report PASS/FAIL/SKIP (no key, nothing to exercise) and BLOCKED (configured but out of quota), only FAIL affects the exit code, and the local-fetcher cases force `-e local` so they cannot silently route through a keyed engine and spend credits. - The launchers skip npx when node is below the CLI's 22.13 floor: an old node with a working npx used to be selected anyway, a path known to fail at run time. The diagnosis explains an unusable npx and reports `nodeMeetsFloor`, and the no-runtime next step names the actual node version. Doc wording about the launcher is corrected: `doctor` spends no quota, but the npx/bunx paths may download the pinned package on first use, so "fully offline" and "never downloads" claims are gone. ## 5.1.0 - 2026-08-07 - The skill now launches the CLI through a bundled launcher (`skills/modsearch/scripts/run.sh` for macOS/Linux, `run.ps1` for Windows) instead of a hard-coded `npx`, because a Claude Code native install has none of `node`, `npx`, `bun`, or `bunx` on PATH, so a fixed command failed for a whole class of users. Both launchers resolve the same way, forward every argument to the CLI unchanged, and share a `doctor --json` diagnosis: a compatible `modsearch` already on PATH (same major version and not older than the pinned one), then the pinned-version `npx`, then `bunx --bun`, then a structured diagnosis on stderr with `nextSteps` and exit 78 when nothing can run. `doctor` is offline and chains the CLI's own engine/config doctor when a CLI is reachable. The native-artifact branch is a phase-B placeholder that reports none is published yet. The two scripts are POSIX sh and PowerShell 5.1, identical apart from their version constants and shell syntax, and a new `references/runtime.md` documents the pin, the compatibility rule, the diagnostic fields, and why modsearch stays a local CLI (local key, user-owned quota, no central service) rather than a remote MCP. - `SKILL.md` is rewritten to drive the launcher: the Prerequisites section runs `run.sh` / `run.ps1`, keeps a plain-language version of the same resolution order for harnesses that forbid running scripts, and drops the bare-`npx` fallback. Its frontmatter is brought in line with the Agent Skills spec: `allowed-tools` becomes the spec's space-separated string (`Bash`) instead of a YAML list, and a `compatibility` field states the runtime requirement. `INSTALL.md` verifies through the launcher and confirms `scripts/run.sh` copied. - Release tooling stamps the pinned version so it cannot drift. A new `scripts/stamp.mjs` rewrites the version constant in `run.sh`, `run.ps1`, and `runtime.md` from `package.json`, `scripts/release.mjs` calls it on every bump, and `scripts/stamp.test.mjs` fails the build if the three copies ever disagree with `package.json`. The npm `files` list now ships `skills/modsearch/scripts`, and npm provenance is unchanged (already on in the release workflow). - New `INSTALL.md`, an install guide written for the reader who was failing: an AI agent told to install the skill. The reported problem was that even an agent following the README could not complete the install. It is four ordered, idempotent steps (find the skill directory for the harness, copy `skills/modsearch` in, give search one engine, verify with `doctor` plus a real search), each with an explicit "if it fails" branch, Windows notes, and a `doctor` output sample with how to read it. It installs into the user-global skills directory by default, probes for what is already on the machine before installing anything (the README has the user prepare an engine first, so a ready engine is the common case), configures a user-supplied key straight away when one was handed over, and treats agy's browser sign-in as an explicit hand-back to the user. Both READMEs link it from the docs table. - Both READMEs' installation section now runs in execution order: step 1 is the only human part, preparing a search engine (install Antigravity and sign in, or register one free key), and step 2 hands one line to the user's AI, which follows `INSTALL.md` and reports back. The hero one-liner above the fold and the how-it-works section are removed with it. The CLI usage that lived in the README moves to `skills/modsearch/references/cli.md`, since the CLI is driven by the agent, and the README keeps a one-line pointer in the docs table. - The CI test matrix adds `windows-latest` on Node 22 and 24, so the typecheck, test, and build gate is proven on Windows rather than assumed. A new "Platform support" section in both READMEs states support by layer. The CLI, the routing and config logic, and the pure-HTTP engines (`local`, Tavily, Exa, Firecrawl) are cross-platform. `agy` and `grok` are external CLIs whose Windows availability is their own to provide, and modsearch spawns them with no shell, so a native `.exe` on PATH works while an npm `.cmd` shim does not. The cooldown state file's atomic rename replaces the target on Windows but cannot replace a file another process holds open, a harmless edge for a cache that merges on read. - The test suite is made cross-platform so the Windows job is honest rather than red. Suites that spawn a fake `agy`/`grok` CLI are gated on a new `SPAWNS_FAKE_CLI` flag and skipped on Windows, because modsearch spawns engines with no shell and a POSIX shell-script fake is not a runnable image there (Node refuses `.cmd`/`.bat` without `shell: true`, CVE-2024-27980), with no cross-platform fake available. The SIGTERM/SIGKILL escalation test is skipped for that reason and Windows signal semantics, POSIX permission-bit assertions move behind an `expectPosixMode` that is a no-op on Windows, and `withTempHome` now redirects both `HOME` and `USERPROFILE` so `os.homedir()` resolves the fake home on Windows too. `docs/testing.md`, `CONTRIBUTING.md`, `docs/harness-setup.md`, and `docs/troubleshooting.md` document the guards and the Windows paths. ## 5.0.0 - 2026-08-06 - `references/configure.md` now gives the full `~/.modsearch/config.json` structure: a complete example plus a field-by-field table covering the top-level `engine`, `cooldown`, and `allowPrivateNetwork`, and the per-engine `apiKey` / `bin` / `model`. Both READMEs' configuration sections point at it. - Two eval cases added: `search-exa` (a forced `-e exa` search returns ranked results with links) and `fetch-firecrawl` (a forced `-e firecrawl` fetch reads a page as markdown). The runner is unchanged. Since it has no per-engine skip, each case's check treats a missing-key run as "not exercised" rather than a red, so `pnpm eval` stays green on a machine without those keys and only exercises the engine when it is keyed. - Engine spend is now surfaced on the attempt that incurred it. Exa reports a dollar `costDollars` and firecrawl a `creditsUsed`, but the orchestrator collected them into engine meta and then dropped them. A successful attempt now carries `cost` (exa, US dollars) or `credits` (firecrawl) when the engine reported one, both optional and absent otherwise. No existing field changed. `references/output-schema.md` and its drift test document the two fields. - Firecrawl fetch now caps its markdown at the same 50000-character ceiling as the local engine, so no single page can flood a model's context regardless of which fetch engine served it. When the content is truncated, a warning says so. The cap lives in a new `src/providers/limits.ts` leaf module shared by both engines, so it cannot drift. Tests cover truncation and its absence. - The cooldown state file is now merged before it is written, not overwritten. Two processes running at once each wrote their whole in-memory snapshot over `state.json`, so the second write dropped whatever cooldown the first had recorded. Writes now re-read the latest on-disk state, apply just the one change (set or replace an engine, or delete one), and write the union under the same temp-file-plus-atomic-rename, so a concurrent record survives. The same engine keeps whichever cooldown lasts longer, so a shorter later write cannot shorten a live one. Tests simulate two interleaved writers keeping both records, the later-until-wins rule, and a clear that preserves another process's record. - Tavily's monthly-cap errors now enter the quota cooldown. Tavily returns 432 (plan usage cap) and 433 (PAYGO cap) for a spent monthly budget, but `classifyQuota` matched on wording alone, so those status codes missed the regex and nothing was remembered: every run re-hit the wall. The tavily engine now carries the status code into the error message ("out of monthly quota (HTTP 432)"), and `classifyQuota` reads 432/433 as the monthly quota class and holds the engine for 24 hours (a new `MONTHLY_COOLDOWN_MS`) rather than the 45-minute default, since a monthly budget will not recover inside the hour. Tests cover both status codes at the message and the classification layers. `docs/troubleshooting.md` documents the codes. - Firecrawl fetch now forces a fresh crawl with `maxAge: 0`. Firecrawl's scrape endpoint defaults to a multi-day cache and returns cached content within it, so a fetch could hand back days-old page text, which is fatal for a tool built around current information. The scrape request now disables the cache, at the cost of a credit per fetch. `references/configure.md` documents the trade in the firecrawl section. - Firecrawl no longer leaks a literal private target to the cloud when the private-network switch is on. The fetch path passed the `allowPrivateNetwork` waiver into `isReservedTarget`, and that check returns early (skips every test) once the switch is set, so a URL with a literal reserved IP or an inherently local name was sent to Firecrawl's cloud crawler. The decision is now two layers: `isLiteralReservedTarget` (a new sync check in the network module) always skips a literal private/reserved target, an IP in a reserved range, `localhost` and `*.localhost`, `*.local`, `*.internal`, and the metadata-host blacklist, regardless of the switch, so it falls to the local engine. Only a public-looking host that resolves to a reserved IP (a VPN fake-ip) still follows the switch: sent up when waived, skipped otherwise. Tests cover a literal `10.x` skipped with the switch on, a fake-ip domain sent up with the switch on, and skipped with it off. - **BREAKING**: `allowPrivateNetwork` is promoted from `engines.http.allowPrivateNetwork` to a top-level config key, and its type is a real boolean (`true`/`false`) instead of the string `"true"`/`"false"`. It is a global network policy, not an engine setting: it governs both the local fetcher and firecrawl's private-network skip, so it does not belong to any one engine. The old per-engine position and the old string form are both read and promoted automatically on load, so existing files keep working. `modsearch config set allowPrivateNetwork true` sets it, and `config init`'s guidance, `config show`, and `doctor` all report the top-level key. The `--allow-private-network` flag is unchanged: still a one-off override for the current run. - **BREAKING**: the built-in direct fetcher is renamed from `http` to `local`. Its identity is the local direct fetcher, and the docs always called it that. `http` and `direct` stay as aliases, so `-e http`, `-e direct`, and old config keys keep resolving to it, and the cooldown state file reads the old keys too. The output `engine` field, `doctor`, error messages, both READMEs, `SKILL.md`, `references/output-schema.md` (with its drift test), `references/configure.md`, and `docs/troubleshooting.md` now all say `local`. The fetch order is now `antigravity-cli`, `firecrawl`, `local` (still the unconditional floor). ## 4.1.0 - 2026-08-06 - New **Exa** search engine (`src/providers/exa.ts`). A keyed, in-process engine in the same shape as tavily: one POST to `https://api.exa.ai/search` with an `x-api-key` header and an AbortController that cancels on timeout. It joins the search order after Tavily, maps highlight snippets and `publishedDate` into the shared item shape, and writes a mechanical summary because Exa ranks and links without synthesizing an answer, with a warning that says to read `items` directly. A 401 or 403 reads as a key problem with a `config set` fix, a spent balance reads as a quota error. The key comes from `EXA_API_KEY` or `engines.exa.apiKey`. Free budget: $10 of recurring monthly credit, about 1,400 searches, no card. - New **Firecrawl** engine (`src/providers/firecrawl.ts`), serving both search and fetch from one file. Search maps ranked web results like the other keyed engines. Fetch is the reason to wire it in: it runs a real browser in the cloud, so JavaScript-rendered pages come back with content the local `http` engine cannot see, and it sits between agy and the `http` floor on fetch. A private or reserved target is validated first and skipped, because a cloud crawler cannot reach it, so the run falls through to the local `http` engine (the `--allow-private-network` waiver carries through). A 402 or a credit message reads as a quota error, a 401 or 403 as a key problem, and a page `statusCode` outside 2xx is a failure. Adds `isReservedTarget` to the network module, an advisory check that is deliberately not a security boundary, so the http engine's SSRF guard stays the sole gate. Free budget: 1,000 credits a month, no card. - New **quota cooldown failover** (`src/cooldown.ts`), on by default. When an engine fails with a quota-class error, it is remembered in `~/.modsearch/state.json` (separate from `config.json`, written through a temp file and an atomic rename) and moved to the back of the fallback chain until it recovers, so a later run fails over to a healthy engine first instead of hitting the same wall. This is a softened circuit breaker, not load sharing: the base order the user picked never changes, a cooling engine is only tried last and is never dropped, so it still answers when everything else fails, and a success clears its cooldown at once. A precise reset time in the message (agy's `Resets in 94h19m9s`) is honored, otherwise the cooldown lasts 45 minutes, and a per-second rate limit is transient and never recorded. An explicit `-e`/`--engine` ignores cooldown entirely. The switch is the config key `cooldown` (`on`/`off`, default `on`): off reads and writes no state and routes exactly as before. New `modsearch state clear` forgets every cooldown, and `modsearch doctor` shows the switch and anything cooling now with the time left. The result's `warnings` name any engine that is cooling and until when. - The search order is now `antigravity-cli`, `tavily`, `exa`, `firecrawl`, and fetch is `antigravity-cli`, `firecrawl`, `http` (`http` stays the unconditional floor). Both READMEs, `SKILL.md`, `references/configure.md`, and `docs/troubleshooting.md` document the two engines and the cooldown switch. The output-schema reference is unchanged, since no field changed. ## 4.0.0 - 2026-08-06 A review pass fixing stale docs, a swallowed error, brittle tests, and thin release tooling. - **BREAKING**: the minimum Node version is now 22.13. Node 18 and 20 are no longer supported, and the CI matrix drops them for Node 22 and 24 on both ubuntu and macos. The build target moves to `node22`. `undici` (kept out of the Node-18 era on `^6`) moves to `^7`: that is the newest major compatible with the 22.13 floor, since `undici@8` requires Node `>=22.19.0`, above what we promise here. The DNS-pinning fetch path is unchanged, and its IPv4/IPv6/mapped-form/redirect tests still pass on undici 7. README (both languages) and `CONTRIBUTING.md` now state Node 22.13+. - New `modsearch doctor` command diagnoses configuration and routing on the local machine without spending quota or making a network request. It reports the Node version against the floor, the effective search-engine choice and where it came from, the config file path and permissions, the `allowPrivateNetwork` state, and for each role (search / fetch / social) every candidate engine's readiness with the reason (binary on PATH, key from env or file, Grok login file present) and a copyable fix for anything missing. `--json` prints the report as JSON. The logic lives in `src/doctor.ts` with branch tests in `src/doctor.test.ts`. Both READMEs gain a line and `docs/troubleshooting.md` now opens with "run `modsearch doctor`". - An eval harness (v1) lands in `evals/`. Unit tests stay offline, and these run the built CLI end to end against real engines and the network, and write one evidence artifact per case (command and date, tool version and commit, engine and model, query or URL, raw output, the expectation and pass/fail, latency, and the warnings/attempts/uncertainty channels) to `evals/results//` (gitignored). Five seed cases cover a time-sensitive fact, a single-page fetch of this repo's README, the X degrade on a machine with no Grok, a JS-rendered page read thin, and the SSRF localhost refusal. `pnpm eval` runs them: the runner reads `modsearch doctor --json` and skips any case whose engine is missing, while the SSRF case always runs offline. `evals/README.md` documents the artifact format and `AGENTS.md` the conventions. - Web and X now run concurrently. A `--source web,x` run issued its two source plans one after the other. They run in parallel now and reassemble in request order, so the run costs about the time of its slowest source instead of their sum. Each source is independently fault-tolerant: when one source exhausts its engines, it becomes an explicit `status: "unavailable"` entry (with the per-engine failures preserved in `attempts`) rather than sinking the whole run, while a single-source run still errors exactly as before. Tests cover the concurrent timing and a one-source-fails-one-succeeds run. - **BREAKING**: a results entry's `uncertainty` no longer mixes routing notes with the engine's own doubt. It splits three ways. `uncertainty` now carries only epistemic facts the engine was unsure about (gaps, conflicts, staleness, a page too thin to trust). A new `warnings` array carries routing and runtime notices that used to be prepended to `uncertainty`: an engine that failed and was replaced, a degrade to a stand-in corpus, a config typo, the http engine's "no synthesis" / truncation / redirect / private-network notices. A new `attempts` array records every engine tried for the source, in order, as `{ engine, ok, error?, durationSeconds }`. A downstream consumer that parsed routing information out of `uncertainty` must read `warnings` (and `attempts`) instead. `references/output-schema.md`, the drift test, `SKILL.md`, `docs/troubleshooting.md`, `references/configure.md`, and both README output examples are updated to match. - `vitest` and `@vitest/coverage-v8` are pinned to the same exact version (3.2.4). The coverage tool must match the runner, but `vitest` carried a caret while the coverage plugin was pinned, so a minor bump could have split them. - The npm tarball now ships `docs/`, `CHANGELOG.md`, and `SECURITY.md`. They were referenced from the README but excluded from the published package. - The release is published by CI alone, closing a double-publish race. `pnpm release` used to push the tag and then run `pnpm publish` locally, while the same tag also triggered the publish workflow, so two processes could publish the same version. `scripts/release.mjs` now stops after the tag and push and prints that CI takes over, and the workflow gained the GitHub-release step (with `contents: write`) it was missing, so the tag push does the whole release: npm publish with provenance plus the GitHub release, its notes pulled from the CHANGELOG. - The DNS-rebinding gap in the local fetcher is closed. The safety check resolved the hostname, but `fetch` then resolved it again, so a DNS answer that changed in between could point the socket at an address the check never saw. The check now returns the exact validated IP, and the connection is pinned to it through an `undici` dispatcher with a custom lookup, while the Host header and TLS SNI keep the original hostname. Every redirect hop re-validates and re-pins. Tests cover IPv4, IPv6, `::ffff:` mapped forms, and a redirect that changes target. `docs/security.md` now describes the gap as closed. Adds `undici` as a dependency (externalized from the bundle, `pnpm audit --prod` stays clean). - `config show` prints the effective config, not just the file. It read the file and masked keys but ignored environment variables, so a key set only in `TAVILY_API_KEY` showed as absent even though every run uses it. It now merges env the same way `engineSettings` does, tags each value with its origin (`file` or `env`), folds alias engine keys (agy, antigravity, grok, direct) onto their canonical names, and keeps the masking. Tests cover env injection, env-over-file precedence, and alias normalization. - A web result standing in for X is no longer labeled `source: "x"`. When Grok Build is missing or fails, a web engine answers the X request, but the entry used to claim `source: "x"`, so a machine consumer would read second-hand web pages as X originals. Each results entry now carries `requestedSource` and `status` (`ok` / `degraded` / `unavailable`): a degraded entry reads `source: "web"`, `requestedSource: "x"`, `status: "degraded"` with the reason in `uncertainty`. A `--source web,x` run where X is unreachable now returns an explicit `status: "unavailable"` X entry with empty `items` instead of silently dropping the slot. This is a backward-compatible field addition, no existing field changed meaning. `references/output-schema.md`, the drift test, and `SKILL.md`'s "read the result" guidance are updated to match. - `-e`/`--engine` is now a hard force, matching what the docs always claimed. It used to put the named engine first and then append every other usable engine, so a forced engine that failed silently fell through to another one and spent its quota. It now uses exactly the named engine with no fallback and no http floor: if it cannot do the job or fails, the run errors and the message says to drop `-e`. The `engine` in the config file is unchanged, still a soft preference backed by the role defaults and the fetch floor. - The `tavily` engine talks to the REST API directly instead of through `@tavily/core`. The SDK pulled ~30 production dependencies (an axios chain carrying several high-severity advisories) to wrap a single POST. It is replaced with one `fetch` to `https://api.tavily.com/search`, a Bearer key, and an `AbortController` that genuinely cancels the request on timeout. `pnpm audit --prod` now reports no known vulnerabilities, and 28 packages left the tree. - A subprocess that ignores SIGTERM now actually gets SIGKILL. The escalation checked `!child.killed`, but `child.killed` flips to true the instant a signal is *sent*, so a process that swallowed SIGTERM read as "already killed" and never got the follow-up SIGKILL, leaving it to run out the full grace window and beyond. The check now tracks the real `exit` event and escalates while the child is still running. Covered by a test that traps SIGTERM in a real child and asserts its PID disappears. - The output-schema reference described the v2 envelope (a top-level `provider`, a single `result` object, `meta.model`/`conversationId`/`usage`) that no longer exists. It is rewritten for the v3 results-array output, and a drift test fingerprints the documented example envelopes against a live `RunSearchResult`, so the doc cannot rot without a test failing. - The agy quota reset time reaches the user again. A non-success envelope threw a bare status and dropped `envelope.error`, so the "Resets in 94h" that `docs/troubleshooting.md` promises never printed. The thrown message now carries it. - Test scaffolding is pulled into `src/testing/helpers.ts`. The search and config suites had each reimplemented the same fake-agy builder, bare environment, and temp config path. `src/fixtures/` was dead code with zero references and is now wired into the suites that assert on those exact shapes. The two fetch tests that hit `example.com` run against a loopback server, restoring the offline-tests rule. - Release changelog parsing had two regex bugs. The version dots were never escaped, so a version could match a look-alike heading, and the section lookahead ended at `\Z`, which JavaScript reads as a literal `Z`, so releasing the oldest changelog version failed to match at all. The logic moved behind a tested helper. - Both READMEs: the CLI tables gained `--prompt` and `--workdir`, and the "~30,000 token" figure now states its measurement (one 2026-08 reading, DeepSeek-V4-Flash through Codex's Responses API endpoint) instead of floating unsourced. - The disclaimer no longer contradicts the MIT license. "Personal study only, not commercial use" denied a right MIT grants. It now limits nothing and points at the upstream engines' own terms. - The Chinese README's promo block points at the WeChat public account, with the follow QR, instead of a second install pitch, and its star call-to-action is ModSearch alone. The English README keeps its liustack block. - Terminology settled on "engine", not "provider", everywhere a user can see it: the subprocess error text, the grok and tavily headers, and `AGENTS.md`. Legacy config keys keep their real v2 names. - `AGENTS.md` corrected: the engine override is `-e`/`--engine` (not `-p`), the v2-default framing is gone, the skills tree lists `configure.md`, and the timing matches `SKILL.md`. - Small cleanups: grok's max-posts fallback matches the real default of 8, `callEngine`'s request type is named directly, and `config init` lists `allowPrivateNetwork`. - The 894-line http fetcher split into three modules (network guards, HTML extraction, engine adapter) with tests following. No behavior changed and no SSRF check was lost. - Open-source scaffolding added: `CONTRIBUTING.md`, `SECURITY.md`, a Contributor Covenant code of conduct, issue and pull-request templates, and a weekly dependabot config. - Tooling and CI: Biome for formatting and linting, v8 coverage reporting, a node 18/20/22 by ubuntu/macos test matrix now that unit tests stay offline, a lint job, and a tag-triggered npm publish workflow with provenance. ## 3.3.0 - 2026-08-06 - README rebuilt against how widely used projects actually write theirs (uv, crush, openclaw, hermes-agent, and current guidance): install command inside the first screen, a nav row and badges in the hero, short scannable highlights, and roughly 1,000 words instead of a long read. Detail moved into `docs/harness-setup.md` and `docs/security.md` rather than being inlined, and a Documentation table points at all of it. ## 3.2.7 - 2026-08-06 - README rewritten rather than patched again. The token argument had been made five separate times (opening, feature list, its own section, the comparison table, the Codex section), the hero ran nineteen lines before the first heading, the feature list and the capability table said the same things, and the comparison compared on weak axes ("setup cost" and "who pays those tokens" are one question). Twelve sections became eight, and phrases that announce honesty rather than demonstrate it are gone. ## 3.2.6 - 2026-08-06 - The flow diagram says something again. Replacing ASCII art with an abstract illustration removed the labels along with the alignment chore, which was a bad trade: a picture of three icons tells a reader nothing. It is now a rendered diagram with real labels, one per language, generated from HTML so nothing drifts. ## 3.2.5 - 2026-08-06 - README: drops the roles vocabulary. Telling a reader there is one setting and then handing them a table of three roles and four engines contradicts itself, and roles are an internal idea anyway. The section now answers what a reader actually asks: what do you want to do, and what does it take. ## 3.2.4 - 2026-08-06 - Releases are now one command: `pnpm release ` refuses a dirty tree, a non-main branch, a duplicate tag, or a version with no CHANGELOG entry, then runs typecheck, tests, and build before anything irreversible happens, and finishes with tag, push, npm publish, and a GitHub release. Publishing by hand is how a version once reached npm with no changelog and no tag behind it. - Every previously published version now has a git tag, reconstructed from the commit that carried it. ## 3.2.3 - 2026-08-05 - README: adds a scannable feature block and a requirements line, which were missing entirely, so what you get is readable without consuming prose. - New `docs/troubleshooting.md`: every error this CLI prints, with cause and fix, linked from the README and the skill. - The ASCII flow diagram is now a real illustration. Its alignment had needed repair across several releases, which is a poor trade for a picture. - Test scaffolding moved into `src/testing/helpers.ts` and realistic engine samples into `src/fixtures/`. Fake binaries, temp HOMEs, and temp config paths were reimplemented per test file, so a fix in one copy left the others behind. - The Gemini CLI era research doc is marked historical so it is not read as current design. ## 3.2.2 - 2026-08-05 - Docs: the README led with "your model's built-in search is a bit weak", which framed the whole project as a patch for one model's shortcoming and gave a reader with working search no reason to keep reading. It now leads with the cost that applies to everyone: built-in search pushes whole pages into your model's context, measured at about 30,000 tokens for one search-heavy answer, against a few hundred for structured evidence. Adds an honest comparison against built-in search and search MCP servers, weaknesses included, and states the two conditions that route a query to X. ## 3.2.1 - 2026-08-05 - `config init` now writes only the shape (`{"engine": "", "engines": {}}`) instead of every engine with its fields pre-filled. The old skeleton buried the single real decision under placeholders, and worse, it wrote today's defaults (`bin: agy`, a model name) into the file, where they would silently outrank any future change to those defaults. The command prints what can be set instead of putting it on disk. ## 3.2.0 - 2026-08-05 **Configuration collapsed to one decision.** Choosing an engine per role turned out to be three questions where users only have one. `~/.modsearch/config.json` now holds a single `engine`, the one that searches. Page fetch uses that engine when it can fetch and the built-in local fetcher when it cannot, and X only ever uses Grok Build, so neither is configurable any more. Older shapes (v2's `provider`, and the per-role grouping that existed for a few hours) are read and mapped automatically. **Fixes a verification pass caught in the 3.1.0 fixes.** The reviewer proved each one. - The subprocess timeout fix never applied: only its constant landed, so a child ignoring SIGTERM still hung the caller. Verified now: a run with a 200ms timeout returns in 216ms instead of waiting out the process. - `stripElement` lowercased the whole document with Unicode rules, which can change a string's length, then used those indices to slice the original. Twenty `İ` characters shifted the indices enough to leak hidden script content into the visible text. - A tag name inside an attribute value (`
`) was read as an unclosed element, dropping the rest of the page. - `--engine grok --source web,x` with Grok absent still ran the web search twice, because an explicitly named engine entered the chain without an availability check. - An engine's result could still overwrite `model` whenever that engine had no model of its own. - Link extraction only recognised quoted `href="..."`, missing `href=https://...` and `href = "..."`. - Tavily's timeout left its deadline timer armed after a fast success. - The streaming decoders were never flushed, so trailing bytes of a split character were dropped instead of surfacing. - `AbortSignal.timeout` rejects with `TimeoutError`, which the abort check did not recognise, so real timeouts were reported as generic request failures. ## 3.1.0 - 2026-08-05 Security and correctness pass after an external review (gpt-5.6-sol) that proved every finding with a probe. **Security** - SSRF bypass: `http://[::ffff:127.0.0.1]` normalizes to `::ffff:7f00:1`, whose hex form the private-range check missed, and the probe reached a service bound to loopback. IPv4-mapped IPv6 is now decoded from the address groups. - Denial of service: the HTML sanitizing regexes backtracked catastrophically. 200 KB of malformed markup took 14.6 seconds and 1 MB ran past 42 seconds, while bodies can reach 2 MB and a synchronous regex cannot be interrupted by a timeout. Element stripping is now a linear scan: the same input takes about a millisecond. - Hang: the request timeout only covered response headers, so a slow body could wait forever. One deadline now spans DNS, every redirect hop, and the body. - Crash: a numeric HTML entity beyond the Unicode maximum threw out of `String.fromCodePoint` and killed the fetch. Out-of-range and surrogate code points are left as text. **Correctness** - `--source web,x` with no Grok ran the same web search twice, billing it twice. The degraded X entry now folds into the web entry it duplicates. - A mid-run Grok failure fell back to a web engine but kept `source: "x"` with no caveat, presenting second-hand web evidence as X coverage. The degrade note now travels with the plan. - An engine's own result could overwrite `source`, `engine`, and `model`. Routing facts are applied last. - Legacy config migration dropped fields when old and new shapes named the same engine, and ignored alias names like `agy` and `direct`. Merging is now per engine and alias-aware. - Tavily ignored `--timeout` and used its SDK's 60 second ceiling. - `--source ""` silently ran the default sources instead of erroring. - A typo in `--engine` was buried under generic setup advice. - Config files that exist but cannot be read (permissions) silently became empty configs. - Subprocess: a timeout only sent one SIGTERM and waited, so an engine ignoring signals hung the CLI. It now settles at once and escalates to SIGKILL. Output decoding kept no state across chunks, so a multi-byte character split across a chunk boundary became replacement characters. - Link extraction ignored `` and left entities undecoded in URLs. ## 3.0.1 - 2026-08-05 - Docs: the README output examples still showed the v2 single-object shape. Both languages now show the v3 `results` array, and both blocks parse as valid JSON. ## 3.0.0 - 2026-08-05 Breaking. modsearch used to treat every engine as one flat list with a single `-p` flag and a routing chain between them, which put a web search engine, an X client, and a page fetcher on the same axis. They are three different jobs, so they are now three roles. - **Roles**: `search` (public web: antigravity-cli, tavily), `fetch` (one URL: antigravity-cli, http), `social` (X: grok-cli). Each role picks its own engine, so changing one never disturbs another. - **Sources instead of engine swapping**: `--source web`, `--source x`, or `--source web,x`. X coexists with web search rather than replacing it. An X-flavored query still goes to X alone by default, spending no agy quota. - **Output contract**: `results` is now an array with one entry per source, each carrying `source`, `engine`, and that engine's result fields. Always an array, so the shape never changes. Replaces the old single `result` object. - **Config by role**: `~/.modsearch/config.json` gains `search`/`fetch`/`social` blocks plus one `engines` map. `modsearch config set search.engine tavily` pins one role. Old configs (`provider` plus `providers`) are read and mapped automatically. - **Page fetch cannot be configured into failure**: a wrong engine name, a missing binary, or a runtime error all fall through to the local `http` engine, which needs nothing installed. - **Zero config, zero install still works**: fetch runs anywhere, and when no search engine exists the error lists both ways to get one instead of naming a single dependency. - CLI: `-p/--provider` becomes `-e/--engine`, and `--x`/`--no-x` become `--source`. Internals split into `router.ts` (all routing), `subprocess.ts`, and `system.ts`. ## 2.6.0 - 2026-08-05 - New `http` engine for page fetch, ported from the retired modfetch project: a plain HTTP request plus text and link extraction, with no dependencies, no key, and no quota. Page fetch now prefers `antigravity-cli` and falls back to `http` when agy is missing, so `-u` works on a machine with nothing installed. Honest trade: no LLM synthesis, no focus narrowing, and JavaScript-rendered pages come back thin. - The port keeps modfetch's SSRF guards (blocked hostnames, private IPv4 and IPv6 ranges, cloud metadata endpoints, every redirect hop re-checked, size and character limits). VPNs that map public hosts into reserved ranges trip those guards, so the block message names that case and `--allow-private-network` (or `http.allowPrivateNetwork` in config) opens it. ## 2.5.1 - 2026-08-05 - Engines now declare which modes they can serve, and asking one for something it cannot do says exactly that. The message adapts to the machine: it names other engines that are actually set up, and when none can fetch a page it says so once and moves on instead of insisting the user adopt Antigravity CLI. A user running Tavily alone is never told they did something wrong. - Skill: new `references/configure.md` with per-engine setup, a capability table, pinning, and troubleshooting, so asking an agent "how do I configure modsearch" gets real answers instead of guesses. ## 2.5.0 - 2026-08-05 - Layered config at `~/.modsearch/config.json` via `modsearch config init/set/show` (0600 perms, keys masked on show), mirroring modlens. Store the Tavily key without exporting an env var, pin one engine to disable routing, or point at custom agy/grok binaries. Flags beat env vars, env vars beat the file. - The skill can do it for you: ask your agent to set a key or switch engines and it runs the commands. - The tavily provider's missing-key error now names both ways to supply one and links the free tier. ## 2.4.3 - 2026-08-05 - Docs: README now documents all three engines including Tavily (previously missing entirely), states agy's weekly quota reality instead of promising a "completely free" solution, moves the X search story out from behind the CLI table, and corrects the DeepSeek built-in search facts (both official protocol endpoints carry `web_search`, `/chat/completions` does not, which is what shuts OpenCode and Pi out). ## 2.4.2 - 2026-08-05 - Fix: runs with the `antigravity-cli` provider could hang until the timeout killed them, the same defect reported against modlens ([modlens#1](https://github.com/liustack/modlens/issues/1)). agy exits cleanly but leaves a language server holding the inherited stdout pipe, so the child's `close` event never fires. Provider runs now settle on `exit` plus a short drain window and release the pipes afterwards. ## 2.4.1 - 2026-08-05 - Reverted the browser-scraping provider shipped in 2.4.0 (yanked). Google serves a captcha to Playwright-driven Chrome even headed, with anti-detection flags and a persistent profile, and a plain HTTP fetch gets a JS-only shell with no results in it. A search engine that answers with a captcha is not a search engine, and falling back to a second-tier index was not worth the dependency. 2.4.0 users get 2.3.0 behavior back, minus the Playwright install. ## 2.3.0 - 2026-08-05 - X handling redesigned from companion source to routing: an X-flavored query now runs entirely on Grok Build (`grok-cli`) when it is installed and signed in, spending no agy quota at all, and silently falls back to the default provider when grok is absent or fails mid-run. The short-lived `x` section from 2.2.0 is gone: every engine returns the same `{ summary, items, uncertainty }` contract, X posts arrive as items (`source: "x.com"`, title carries the handle), and `provider` names the engine that answered. - `grok-cli` is also selectable explicitly (`-p grok-cli`); explicit `-p` always beats routing. `--x` forces the route, `--no-x` pins the default provider. ## 2.2.0 - 2026-08-05 - X (Twitter) companion source: for X-flavored queries, `-q` now also searches real X posts through a locally installed [Grok Build CLI](https://x.ai/news/grok-build-cli) and attaches them as an `x` section next to the main result. Activates only when `grok` is installed and signed in (SuperGrok or X Premium) and fails silently: no grok, no X keywords, or any grok error simply means no `x` section. `--x` forces it, `--no-x` disables it, `--grok-bin` points at a custom binary. - Salvage parsing for grok's after-the-fact schema validation: when the model emits concatenated JSON objects and `structuredOutput` comes back null, the last valid X result is recovered from the raw text. - Build hardening: every Node built-in is externalized in the bundle (an `os` import was silently bundling to undefined). - Project hygiene: tests co-located with sources (`src/*.test.ts`, no `test/` directory), GitHub Actions CI, this changelog. ## 2.1.0 - 2026-08-02 - Tavily search provider ported to the v2 contract (search mode only, needs `TAVILY_API_KEY`). Originally contributed by @mani2001 against v1. ## 2.0.0 - 2026-08-01 - Breaking: engine migrated from the discontinued Gemini CLI free tier to Antigravity CLI (`agy`). - Absorbed the retired `modfetch` project: one CLI, `-q` searches, `-u` fetches. - Schema-enforced structured output via `--json-schema`; `result`/`meta` envelope; fabricated relevance scores dropped.