mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
next
80 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8476db7970 |
feat(fetch): finish the ladder — rung 2 recovers SPA pages browser-free
The measured gap: developer.apple.com/documentation/swiftui/view converts to
36 B of text from a 17,486 B shell, and reactnative.dev/docs/view ignores the
Accept header entirely. Both publish the article as a .md sibling. Rung 1
could not reach either; nothing below rung 1 existed.
Rung 2 now fires ONLY when the cheaper rungs did not produce an article, so
the happy path still costs exactly one request (asserted against a request
log, not inferred):
2a the page's .md sibling
2b the host's llms.txt, followed only when it names this page somewhere 2a
did not already try
Acceptance is structural: a sibling is taken unless the server handed back an
HTML document. developer.apple.com serves its .md with an EMPTY Content-Type
and an HTML comment as its first bytes, so a 'starts with #' test would reject
a real article; angular.dev answers a missing .md with 200 + the SPA shell, so
status alone would accept a soft 404.
Every fetch now reports WHICH RUNG ANSWERED (stdout line 4) and which rung-2
urls were requested (line 5). The honest refusal names them instead of telling
the caller to go looking for files the ladder already asked for.
classifyExtraction is injected into the subprocess the same way classifyIp is,
so 'is this a shell?' has one definition and two callers. Its thresholds moved
inside the body to survive .toString() under esbuild minification — verified
against the shipped bundle, not just the source.
Also: 'SSRF blocked: redirect chain exceeded' no longer accuses an attack when
a benign locale redirect loop produces it (measured on Google devsite hosts).
Measured with scripts/measure-fetch-ladder.cjs over 36 documentation pages.
|
||
|
|
096f9330ae |
docs(fetch): the measurements, including the one that corrected the brief
Settles the caveat first: the 1,485,503 B Turndown figure came from a RAW call. The shipped path has always run `td.remove(['script','style','nav','header','footer','noscript'])`, and its real output for the Stripe charge page is 26,053 B. The defect was never byte volume — it was that 28.3% of non-blank lines were link-only and the article's field definitions were not in the document at all. Mintlify is the case that kills every byte threshold: the HTML arm produced a SMALLER document (6,223 B vs 9,549 B) that did not contain the article, because that page is client-rendered. Fewer bytes is not the goal. Also records the three invariants read back from the live production store after the claude -p runs, including csv.html reading back with 17 template blocks after being fetched as a cold-start page with 0 — the re-run, persisted. |
||
|
|
5b9c00c965 |
feat(fetch): extract the article instead of transliterating the page
A format converter answers "what format". It can never answer "which part of the page". We were using a transliterator where an extractor belongs, which is why link-density and byte thresholds kept failing: the correct threshold is 28.3% link-only lines on docs.stripe.com and 0.3% on resend.com, so no single number separates them. Two changes, cheapest correct answer first. 1. ASK FOR THE MACHINE-READABLE PAGE. The fetch subprocess now sends `Accept: text/markdown, ...;q=...` on the SAME request it was already making — zero extra round trips, and the q-values keep it a superset of the old request, so no site can newly break. Measured 2026-08-12, all six previously-failing platforms honour it. 2. CLASSIFY WHAT IS LEFT. Chrome is what REPEATS ACROSS PAGES OF THE SAME HOST; content is what does not. A block is labelled `template` only when that exact block was already seen on a DIFFERENT page of the same host, so the rule never guesses from the shape of a single page. NO DATA LOSS MEANS LABEL, NEVER DROP. The complete document is stored verbatim in fetch-pages.db along with every block and its label; only `content` blocks reach the FTS index. `reassemble(splitBlocks(x)) === x` byte for byte — asserted over 14 samples including CRLF, fenced code and unicode, and verified live on six real pages. COLD START: the first page of a host has no comparison set, so every block is admitted as content and the page is marked PROVISIONAL, then re-run the moment a second page of that host lands. Guessing per-block on page one was rejected on the measurements above; over-indexing is recoverable, and a first page wrongly labelled and never revisited is a silent loss. A page whose every block already exists on other pages of the host is REFUSED rather than reported as a success — a 21-byte "success" that indexes a page title stops the model looking, where an error makes it try another route. The refusal names llms.txt, .md and OpenAPI as next steps. Its bytes are stored anyway: refusing to index is not a licence to discard. No regular expressions. Nothing truncated. |
||
|
|
2608e344bb |
refactor(antigravity-cli): one-command agy plugin install (drop npm wrapper) + doc cleanup (#853)
refactor(antigravity-cli): one-command agy plugin install; drop npm wrapper agy 1.0.7 added GitHub-subpath plugin install (with branch resolution), so the former three-step flow shipped in #787 — `npm install -g` + `git clone` + `npm run install:agy` (scripts/install-antigravity-cli-plugin.mjs) — is dead weight. agy (<=1.0.6) `plugin install` accepted only a local directory, which is why the wrapper existed; that constraint is gone. Install is now one command, no clone, no wrapper: npm install -g context-mode agy plugin install https://github.com/mksglu/context-mode/tree/main/configs/antigravity-cli - remove scripts/install-antigravity-cli-plugin.mjs + the install:agy npm script and its files[] entry - antigravity-cli doctor `fix` strings now point at the one-command install - README: split the conflated "Antigravity" entry into Antigravity IDE vs Antigravity CLI (agy); bring the agy section to the other install guides' level (Prerequisites / Install / MCP-only / Verify / Routing / Full configs); Verify points to the existing "Try It" prompts. Deep mechanics and troubleshooting stay in docs/platform-support.md - docs/platform-support.md: one-command update + a "Verified: agy 1.0.10" note recording the >=1.0.7 install floor; hook contract unchanged through 1.0.10 (config/hooks.json canonical since 1.0.8) - tests: drop the wrapper-shape regression assertions (no leftover trace); bundle-content tests still guard the installable artifact Preserved invariants: the bundle still registers MCP via its native mcp_config.json (command: context-mode, env-pinned CONTEXT_MODE_PLATFORM=antigravity-cli); the dual hooks.json + hooks/hooks.json is kept (agy runtime reads root, validate reads subdir). Verified on agy 1.0.10 (Linux): clean-room single-command install registers MCP + hooks + skill from a zero baseline; tools/list exposes 11 Gemini-safe ctx_* tools (0 const / 0 additionalProperties); `agy -p` smoke returns 12. npm run build + tsc --noEmit + targeted vitest (47) pass. Bundles are CI-managed (bundle.yml) and not included. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1aee4808d0 | fix(install): derive version from package.json across all manifests so none bake stale (#768) | ||
|
|
9f34c6f11b |
Add GitHub Copilot CLI + Antigravity CLI (agy) support (#787)
* feat(adapters): add Antigravity CLI (agy) + GitHub Copilot CLI support
Add two agentic CLI adapters onto next's existing adapter registration —
without the abandoned PR's setup subcommand / consolidated registry.
Antigravity CLI (agy):
- MCP + capture-only PostToolUse hook adapter (agy honors no stdout veto in
auto-run mode; verified against agy 1.0.5). The agy hook payload
{conversationId, toolCall, workspacePaths} is mapped onto the shared
capture pipeline.
- Ships a Claude-layout plugin bundle (configs/antigravity-cli/) installed via
`npm run install:agy` (mirrors install:openclaw), with a version-skew
capture-hook probe in the installer.
GitHub Copilot CLI (1.0.59):
- json-stdio hook adapter with six events: PreToolUse, PostToolUse, PreCompact,
SessionStart, UserPromptSubmit, Stop. Overrides CopilotBaseAdapter to emit the
FLAT {type,command} + top-level "version": 1 hook config Copilot CLI requires.
- MCP install via `copilot mcp add context-mode -- context-mode`.
- Fix a latent Stop-hook bug: a session_end event with no `data` threw inside
insertEvent (createHash(undefined)) and was silently dropped.
Cross-cutting:
- #774: probe agy/copilot config markers before the generic ~/.claude check.
The copilot marker is narrowed to context-mode-written files
(~/.copilot/mcp-config.json | hooks/context-mode.json), not a bare ~/.copilot/
dir, so a co-installed-but-unconfigured Copilot CLI cannot steal detection
from a Claude Code user.
- Dispatcher fails OPEN (exit 0) on a missing hook script: GitHub Copilot CLI
treats an exit-1 PreToolUse hook as DENY, so a version skew (a newer adapter's
hook command on an older global) would otherwise brick the agent.
Fixes #774. Fixes #775.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: regenerate bundles for antigravity-cli + copilot-cli support
Picks up the new HOOK_MAP entries, client-map keys, validPlatforms,
getSessionDirSegments cases, and the fail-open dispatcher into the
esbuild-generated runtime bundles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(platform-support): sync support docs to 18 platforms + fix stale Kiro classification
Make README.md and docs/platform-support.md internally consistent and aligned
with the adapter source of truth.
Header sync (18 platforms everywhere):
- The Main Comparison Table (was 11 cols), the Capability Matrix (was 11), and
the README Platform Compatibility table (was 17, missing Kimi Code) now list
the SAME 18 platforms in one shared order. Adds the two branch-new platforms
(GitHub Copilot CLI, Antigravity CLI `agy`) plus previously-omitted Qwen Code,
KiloCode, OpenClaw, Zed, Pi as columns. Each cell sourced from the per-platform
detail sections / adapter source and independently verified.
- Fix five ragged rows in the Main Comparison Table (a dropped trailing OMP cell)
and add CLI Hook Dispatcher rows for qwen-code + copilot-cli.
- GitHub Copilot CLI section: normalize the `**Hook Names:**` label and add the
missing `**Output Modification:**` field for json-stdio-family parity.
Fix stale Kiro classification (code is the source of truth):
- The kiro adapter is json-stdio with working preToolUse/postToolUse hooks
(hooks/kiro/{pretooluse,posttooluse}.mjs + a kiro HOOK_MAP entry), yet the docs
called it "MCP-only (Phase 2 — not implemented)" and the README contradicted
itself ("no hook support" in one place, "native preToolUse/postToolUse" in two
others).
- Reclassify Kiro as json-stdio with PreToolUse + PostToolUse + exit-code-2
blocking across the Overview paradigm table, both wide tables, the dispatcher
table, and the Kiro detail section; document that agentSpawn (SessionStart) and
stop are not yet wired, so session restore after compaction is unavailable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(antigravity-cli): drop vestigial .mcp.json dependency that broke fresh clones
The agy plugin-bundle test asserted configs/antigravity-cli/.mcp.json, but
.mcp.json is gitignored repo-wide and was never committed — so the test passed
on the dev machine (file present locally) yet failed on a fresh clone with
ENOENT. Committing the file is the wrong fix: the .gitignore comment documents
that shipping .mcp.json has silently broken fresh installs before (#253/#531).
- The bundle declares MCP the Claude way via .claude-plugin/plugin.json
mcpServers (committed — the mechanism agy reads on `agy plugin install`),
mirrored by the agy-native mcp_config.json (committed). Remove the vestigial
bundle .mcp.json and stop the test + docs from requiring it. Every file the
plugin test reads is now git-tracked, so a fresh clone passes.
- README: Kiro was still grouped under "Non-hook platforms" in the routing-
enforcement note. Kiro has native preToolUse/postToolUse hooks; it needs the
manual KIRO.md copy only because agentSpawn/SessionStart is not yet wired.
Reword to say so.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(adapters): cross-platform agy installer + copilot-cli COPILOT_HOME parity
Windows fix (real): replace the bash-only agy plugin installer with a
cross-platform Node script so `npm run install:agy` runs natively on Windows
(PowerShell/cmd), not just Git Bash/WSL. agy runs on Windows, so its installer
must too — the old `node -e` wrapper hard-exited 1 on win32. openclaw stays
bash-only (it is genuinely POSIX-only). Removes
scripts/install-antigravity-cli-plugin.sh in favor of
scripts/install-antigravity-cli-plugin.mjs (same preflight + version-skew probe).
copilot-cli hardening (COPILOT_HOME edge case only — the default ~/.copilot
install was and remains correct):
- CopilotCliAdapter.getSessionDir() now roots at getConfigDir() (COPILOT_HOME-
aware), mirroring codex/kimi, so the TS server reads sessions from the same
place the hook runtime (COPILOT_OPTS configDirEnv: COPILOT_HOME) writes them.
Previously a relocated COPILOT_HOME split hook writes ($COPILOT_HOME/...) from
server reads (~/.copilot/...), making sessions appear empty.
- detect.ts copilot-cli marker honors COPILOT_HOME, not just ~/.copilot.
No change to the default (COPILOT_HOME-unset) behavior; a regression test pins
both the ~/.copilot default and the COPILOT_HOME-rooted path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: regenerate bundles for copilot-cli COPILOT_HOME parity
Picks up CopilotCliAdapter.getSessionDir() and the COPILOT_HOME-aware detect.ts
marker into the esbuild runtime bundles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(antigravity-cli): installer registers the MCP server (agy plugin install skips it)
`npm run install:agy` ran only `agy plugin install`, which — verified against
agy 1.0.5 — processes a bundle's skills + hooks but logs "mcpServers : skipped
(not found)" and registers NO MCP server. agy reads a plugin's MCP only from a
bundle `.mcp.json` (intentionally not shipped — gitignored repo-wide after
#253/#531) and has no `agy mcp add` command, so context-mode's MCP server was
never registered: users had to add it to ~/.gemini/config/mcp_config.json by hand
(reported on Windows; reproduced on Linux: `mcpServers : skipped (not found)`).
The installer now also writes context-mode into agy's GLOBAL MCP profile
~/.gemini/config/mcp_config.json (idempotent JSON merge, preserves other servers,
tolerates a malformed file) — the file agy actually loads and `context-mode
doctor` checks. Verified end-to-end on agy 1.0.5: `npm run install:agy` →
mcp_config.json gains context-mode → `agy -p "... ctx_execute ... 7 + 5"` → 12.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): emit Gemini-safe tool schemas so agy/Gemini CLI expose ctx_* tools
Antigravity CLI (agy) and Gemini CLI use Gemini's function-calling API, which
rejects JSON Schema `const` and `additionalProperties`. When a tool's parameter
schema contains either, the host SILENTLY DROPS that tool from the model's
function list — so agy never sees the ctx_* tools and works around them by
hand-rolling the MCP protocol through its Bash tool (verified on Windows: agy
wrote scratch/call_ctx_stats.js + list_mcp_tools.js MCP clients instead of
calling the tools natively). That defeats the point of context-mode — bash
output floods the context window instead of staying in the sandbox.
context-mode builds schemas with Zod, which emits `const` (from coerce/preprocess
constructs) and `additionalProperties`, with no Gemini sanitization. Wrap the
SDK's tools/list handler to rewrite the EMITTED schema:
- `const: X` -> `enum: [X]` (an identical single-value constraint)
- drop `additionalProperties` (advisory-only; every ctx_* handler parses args
with Zod, which strips unknown keys server-side regardless)
Both transforms are behavior-preserving for every other client (Claude Code,
Copilot, Cursor): const and a one-value enum are equivalent, and no model sends
undeclared properties — only the wire schema changes, never validation or how a
tool is called. Best-effort: if the MCP SDK internals shift, the original handler
is left untouched (no regression). Verified on the real tools/list: all 11 ctx_*
tools now emit 0 `const` / 0 `additionalProperties`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: regenerate bundles for Gemini-safe tool schema sanitizer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(antigravity-cli): clear agy's stale MCP tool-schema cache on install
agy caches each MCP server's tool schemas under
~/.gemini/antigravity-cli/mcp/<server>/ and does NOT refresh them on reconnect
(verified on agy 1.0.6 against a live Windows install). A cache captured by a
context-mode older than the Gemini-safe-schema fix (
|
||
|
|
aa1afc01dc |
feat(hooks): plumb jsRuntimePath through normalize-hooks for bun rewrite (#738)
Extends normalizeHooksOnStartup / normalizeHooksJsonOnly with an optional jsRuntimePath parameter. When present (and different from nodePath), the static hooks/hooks.json rewrite swaps the bare `node` prefix for the resolved Bun ≥1.0 path so PreToolUse/PostToolUse fires inherit the same cold-start win as the in-place adapter-generated configs. Lifts the prior platform gate (`win32 || linux`) for the hooks.json branch when a bun swap is requested. The original #378 path stays Windows/Linux-only when only #378's placeholder healing is needed, but macOS now also rewrites when jsRuntimePath !== nodePath — the issue was filed from macOS and the historical gate skipped darwin because system node was reliable, not because the rewrite was unsafe. plugin.json normalization is explicitly EXEMPT from the bun swap (MCP server stays on Node, #543 better-sqlite3 ABI). Callers updated: - start.mjs: probe resolveHookRuntime at MCP boot, forward to normalizeHooksOnStartup. Inner probe wrapped in its own try so a missing build/runtime never blocks boot. - src/cli.ts: /ctx-upgrade also probes + forwards so the upgrade-time healing picks bun. - scripts/postinstall.mjs: global install heal also probes. tests/cli/upgrade-plugin-json-assertion.test.ts widens its source slice window 16k→20k chars: the new bun-probe block pushed healPluginJsonMcpServers past the 16k cap and the downstream `Plugin manifest drift` throw fell outside the per-test slice. |
||
|
|
63d0a3ae19 | fix(upgrade): stop baking stale version paths during /ctx-upgrade (#711) (#713) | ||
|
|
b70498a70a | feat(pi): fix adapter routing, MCP bridge, startup diet, and pricing (#741) | ||
|
|
547c18b69b | security: containment, info-disclosure, and test-isolation hardening (#716) | ||
|
|
fa61570941 |
Fix Claude plugin skills path and pack integrity guard (#661)
* ci: update server.bundle.mjs, cli.bundle.mjs, session hook & security bundles * ci: update install stats * ci: update install stats * ci: update install stats * Fix Claude plugin skills manifest path --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
6cb1490c9b |
fix(family-A): persistence-tier rules — drop stale .mcp.json + portable Tier C hooks (#620)
* fix(family-A): persistence-tier rules — drop stale .mcp.json + portable Tier C hooks Single unified PR for the persistence-tier family. Three issues, one architectural decision: classify every file by who reads / mutates it (plugin-cache vs. user-home vs. workspace-committed) and enforce the correct mutability contract per tier. Issue #604 — hooks.json bidirectional ratchet (already fixed on `next` by merged PR #611 / commit |
||
|
|
09efefdc4a |
feat(opencode): register ctx tools natively via plugin (#574) (#597)
Move OpenCode/Kilo from plugin+MCP dual registration to plugin-native ctx_* tools. The plugin now imports the shared server tool registry without starting stdio, exposes all 11 ctx_* tools via the OpenCode/Kilo tool map, and uses AsyncLocalStorage to pass project/session context into existing handlers without a process.env race. Upgrade safety for existing users: - configureAllHooks removes only legacy mcp.context-mode while preserving other MCP servers. - doctor warns when a legacy mcp.context-mode block remains and points to context-mode upgrade. - stale legacy OpenCode/Kilo MCP children suppress ctx_* registration and become no-op rather than exposing duplicate tools. Safety cleanup: - Remove CONTEXT_MODE_IDLE_TIMEOUT_MS entirely; plugin-native tools remove the need for timer-driven MCP death, and timer shutdown was unsafe for hosts that keep registered tool handles. - Guard process-wide exception handlers so importing server.js for native tools does not alter OpenCode/Kilo host crash semantics. - Scope CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS to the dynamic import and restore it so child commands do not inherit the internal guard. Tests cover native tool registration, native ctx_stats execution, host side-effect leakage, native session attribution, legacy MCP config cleanup, doctor warning, and stale MCP no-op predicate. Refs: #574, #565, #592 Co-authored-by: Ousama Ben Younes <benyounes.ousama@gmail.com> |
||
|
|
3e39ff7dee |
fix(pi): move respawn guard to request() + single-flight + smoke pin (#583 follow-up) (#585)
The original #583 patch put the respawn-on-idle-exit guard inside MCPStdioClient.callTool() only. Three remaining issues identified by audit on top of that fix: 1. tools/list and initialize go straight through request() and miss the respawn guard. If Pi ever calls listTools() after an idle exit (e.g. a tool-list refresh on session resume), it still rejects with "MCP server has exited" the same way the registered tool path used to before #583. 2. Two concurrent callTool() calls after the child exits both observe `this.exited === true`, both invoke respawn(), each spawns its own child. The loser of the race overwrites `this.child` and its child becomes an orphan with no `.kill()` reference — silent process leak. 3. respawn()'s state-reset ordering was undocumented. If a future refactor moves `this.exited = false` to AFTER `await this.initialize()`, the recursive request("initialize", ...) inside respawn() sees `exited === true` and re-enters respawn forever (infinite loop, not just a stale reject) — silent test/CI hang. Fix: - Move the respawn-on-exited guard from callTool() into request() — the single chokepoint for initialize / tools/list / tools/call / any future method. callTool() simplified to delegate. - Add `private respawnPromise: Promise<void> | null` for single-flight semantics: concurrent callers awaiting `request()` after an idle exit observe the SAME respawn promise. Cleared in `.finally()` so the next exit can respawn again. - Document the respawn() state-reset ordering invariant in JSDoc, with an explicit pointer to the new regression test that pins it. Plus a tier-2 smoke harness fix: - `scripts/tier2-smoke/run-pi-smoke.sh` now exports CONTEXT_MODE_IDLE_TIMEOUT_MS=0 at startup. Without this, the smoke's long idle gaps trigger the auto-respawn path, which would mask a real silent-death regression by making it look like a normal idle exit. Regression tests (3 new cases in tests/adapters/pi-mcp-bridge.test.ts): - "listTools() after an idle exit triggers respawn (not just callTool)" Fake server exits after first tools/list response. Second listTools() must respawn + return — pre-fix this rejected because the respawn guard only lived in callTool(). - "concurrent callTool() invocations after exit share ONE respawn (no orphan children)" Fake server records its pid to a marker dir on every boot. Two parallel callTool() calls fire from the exited state. Assertion: exactly TWO pid files on disk (original + one respawn), and both callers' responses carry the SAME pid. Pre-fix without single-flight this would have 3 pid files and divergent caller pids. - "respawn() resets state in the documented order — `exited=false` BEFORE initialize()" Manually flips internal.exited=true (post-onExit shape without an actual kill, for determinism), then callTool() must run respawn → initialize() through request() recursively, which only terminates if `exited` was cleared before the recursive call. Targeted tests: 9/9 pass (full pi-mcp-bridge.test.ts). Full suite: 3331 pass, 48 skipped — same 3 pre-existing environment-dependent failures unchanged from `upstream/next` HEAD (VSCODE_PID inheritance, JetBrains IDEA_INITIAL_DIRECTORY). Refs: #583, #565, #568 Co-authored-by: Ubuntu <omer@Omer.tail8b8831.ts.net> |
||
|
|
e63271b212 |
fix(opencode): detect desktop sessions via OPENCODE_CLIENT/OPENCODE_TERMINAL (#581)
OpenCode desktop sessions now resolve from desktop env markers (`OPENCODE_CLIENT=desktop`, `OPENCODE_TERMINAL=1`) instead of falling through to `~/.claude/` and being misclassified as Claude Code. Verified upstream against sst/opencode: - packages/desktop/src/main/server.ts:64 sets OPENCODE_CLIENT=desktop - packages/opencode/src/pty/index.ts:191 sets OPENCODE_TERMINAL=1 Also threads CONTEXT_MODE_PLATFORM into the doctor child spawned by `upgrade()` so the verification step does not rediscover Claude Code after upgrade() has already resolved OpenCode. Adapter parity preserved across detect.ts, hooks/core/platform-detect.mjs, and scripts/ctx-debug.sh. Fork-before-parent ordering (kilo > opencode) intact. Tests: tests/adapters/detect.test.ts (+2 desktop env tests), tests/adapters/detect-config-dir.test.ts (+1 OPENCODE_CLIENT test), tests/util/ctx-upgrade-platform-threading.test.ts (+1 threading test). 107 tests pass locally. Co-authored-by: Michael <mlalpho@users.noreply.github.com> |
||
|
|
7f1e1e8e29 |
fix(upgrade): heal ~/.claude.json user MCP registrations after version bump (#579)
* ci: update server.bundle.mjs, cli.bundle.mjs, session hook & security bundles * fix(upgrade): heal ~/.claude.json user MCP registrations after version bump Users who work around anthropics/claude-code#59310 (plugin-registered MCP servers don't expose tools to the AI) by registering via `claude mcp add --scope user` end up with an absolute path to a specific version dir in ~/.claude.json. After /ctx-upgrade the path is stale. Detect any mcpServers args in ~/.claude.json pointing inside the context-mode plugin cache and update them to the new pluginRoot. Best-effort — never blocks the upgrade if the file is missing or unparseable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(upgrade): heal ~/.claude.json user MCP registrations after version bump Users who work around anthropics/claude-code#59310 (plugin-registered MCP servers don't expose tools to the AI) by running `claude mcp add --scope user` end up with an absolute path to a specific version dir in ~/.claude.json. After /ctx-upgrade that path is stale and tools stop working. Extract healClaudeJsonMcpArgs() into heal-installed-plugins.mjs (shared module) so it runs during upgrade() and is unit-testable. Detects any mcpServers args inside the context-mode plugin cache and updates the version dir to the new pluginRoot. Best-effort — never blocks the upgrade. 5 new tests covering: happy path, no-op (already current), missing file, no mcpServers key, unrelated server args. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
61e680f1f3 |
fix(v1.0.132): stats measurement + #563/#564/#567/#569 + PR follow-ups
- stats: populate bytes_avoided for external_ref via ctx_fetch_and_index preamble; indexer chunks now FK-attributed (chunks.session_id/event_id) at all 7 callers in server.ts - #563: drop .refine() from ctx_purge schema (MCP SDK normalizeObjectSchema requires .shape); ambiguity check moved to handler; class-wide CI guard added for all 11 tools - #564: engines.node>=22.5 + scripts/postinstall.mjs hard-fail on Linux+Node<22.5+no-Bun + ctx_doctor RED FAIL + README/docs sync to canonical 22.5 floor - #567: vscode-copilot + jetbrains-copilot mcp.json npx-y -> global context-mode (npx-y was scaffold residue from Mar 2026, ghost-installs bypass user's npm i -g causing better-sqlite3 ABI mismatch) - #569: anti-pattern docs centralized to anti-patterns.md §8 + SKILL.md ref (capture-vs-filter principle, no tool enumeration) - #571 follow-up: vswhere timeout 5s->15s, year regex caps at currentYear+5 - #568 follow-up: documented CONTEXT_MODE_IDLE_TIMEOUT_MS + CONTEXT_MODE_STARTUP_SWEEP env vars; realpath guard in lifecycle-e2e-real-binary.test.ts |
||
|
|
8e2d568452 |
fix(win32): detect VS year via vswhere displayName for VS 2026+ (#571)
Closes #566 Adds detectWindowsVsYear() in scripts/heal-better-sqlite3.mjs that queries vswhere.exe for displayName (e.g. "Visual Studio Community 2026") and extracts the 4-digit year via regex. Wired into buildSafeEnv() so npm_config_msvs_version is set automatically on Windows when not already provided, and into upgrade() in src/cli.ts so /ctx-upgrade picks up the same hint. 12 new dependency-injected unit tests (run on any OS, no real vswhere needed) plus 4 source-contract assertions. All 16 tests pass on macOS/ubuntu/windows CI. Co-authored-by: Kishan08 <Kishan08@users.noreply.github.com> |
||
|
|
43b2477575 |
fix(integrity): algorithmic Algo-D4 — derive required siblings from scripts.bundle (closes #558 partial — 3 of 4)
v1.0.126 shipped Algo-D4 with a hardcoded REQUIRED_RUNTIME_SIBLINGS
array that omitted `hooks/security.bundle.mjs` (the bundle didn't
ship until v1.0.127, but the algorithmic intent was already
documented). The hardcoded list silently passed integrity checks on
v1.0.126 marketplace installs even when the security regression
was active — Algo-D4 reported `{ ok: true }` while permissions.deny
was fail-open. The same trap would have re-bitten the next bundle.
Algorithmic redesign:
- Replace `REQUIRED_RUNTIME_SIBLINGS` const with
`getRequiredRuntimeSiblings(pluginRoot)` exported function.
- Algorithm: union of LEGACY_FALLBACK (the v1.0.126 contract,
preserved verbatim) plus every esbuild outfile parsed from
`package.json scripts.bundle` minus an explicit
SOFT_FALLBACK_BUNDLES whitelist (session-* bundles, which have
bundle-first/build-fallback in session-loaders.mjs and don't need
to fail-fast).
- Source of truth: `scripts.bundle` `--outfile=` arguments. Adding
a new bundle to that script auto-extends the integrity check —
no parallel hardcoded list to maintain.
- Safety net: if package.json is unreadable, fall back to the
legacy hardcoded set so the boot gate never goes silent.
- `assertPluginCacheIntegrity` now calls the new function. Public
signature unchanged. start.mjs + the doctor surface are
zero-touch — both consume the same algorithmically-derived set.
Tests (extend tests/core/cli.test.ts per CONTRIBUTING):
- "Algo-D4 algorithmically requires hooks/security.bundle.mjs" —
the headline #558 regression: with security bundle missing on a
fakeRoot, integrity must report ok=false (pre-558 hardcoded check
vacuously passed).
- "Algo-D4 derivation reads scripts.bundle outfiles" — synthetic
package.json proves a future hooks/foo.bundle.mjs is auto-gated,
while soft-fallback session-db.bundle.mjs is correctly excluded.
- "Algo-D4 preserves the legacy hardcoded contract" — anti-
regression pin: every entry in v1.0.126's hardcoded list is still
in the algorithmic set. Strictly additive refactor.
Verified: 152/152 cli.test.ts tests pass (4 new Algo-D4 + 4
pre-existing plugin-cache + 144 unrelated). typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
30b4891840 | Merge remote-tracking branch 'origin/next' into v126/algorithmic-defenses | ||
|
|
a510c37eca |
Fix Windows path compatibility in ctx-debug.sh (#553)
* Fix Windows path compatibility in ctx-debug.sh
* Fix NODE_PATH MSYS2→Windows conversion in ctx-debug.sh
NODE_PATH was set to an MSYS2-style path (/d/Coding/...) that native
Node.js could not resolve, causing all better-sqlite3 and server
resolution checks to fail silently. Added PLUGIN_ROOT to the Windows
path bridge block so its cygpath -m conversion feeds NODE_PATH.
Rejected: hardcode PLUGIN_ROOT to Windows path | brittle, breaks
portable runs
Rejected: remove NODE_PATH override entirely | would break non-local
node_module resolution in adapters
Rejected: delay NODE_PATH export until after path bridge | same end
result, less visible
Constraint: Git Bash's /d/ paths are invisible to native Windows Node.js
Constraint: Node.js on Windows silently treats unknown paths in
NODE_PATH as no-ops — no error, just module not found
Confidence: high
Scope-risk: narrow
Directive: Any future MSYS2 path stored in a variable and passed to
node must be cygpath -m converted first.
Directive: NODE_PATH export happens before the bridge block — the
bridge re-exports it with the converted PLUGIN_ROOT value.
Tested: diagnostic now passes 27/30 (was 22/30), stderr clean
* Remove accidentally committed pnpm-lock.yaml
pnpm-lock.yaml was auto-generated by pnpm install during debugging
and committed by accident in
|
||
|
|
df561af0a8 |
feat(start): plugin-cache integrity check derived from package.json files[] (algo defense 5 of 6)
#550: a partial install (interrupted npm install, broken marketplace pull, half-finished /ctx-upgrade) leaves start.mjs spawnable but a boot-critical sibling (server.bundle.mjs, cli.bundle.mjs, hooks/<event>.mjs) missing. Today the MCP child dies silently downstream — the user sees an opaque "MCP server failed to start" with no actionable signal pointing at the missing files. scripts/plugin-cache-integrity.mjs (new, ships in package.json files[]) exposes: - derivePluginManifest({ pkg, pluginRoot }) — reads files[] from the supplied pkg, expands directories recursively, returns the relative file list. Algorithmic: adding a new entry to files[] auto-extends manifest coverage. No parallel hardcoded list to maintain. - assertPluginCacheIntegrity({ pluginRoot }) — verifies each entry in a frozen REQUIRED_RUNTIME_SIBLINGS list (server.bundle.mjs, cli.bundle.mjs, the 5 hook scripts) exists. Pure: returns `{ ok, missing }` — the caller decides the surface (fail-fast at boot vs. doctor diagnostic). - formatPartialInstallReport({ pluginRoot, missing }) — the structured stderr block start.mjs emits on !ok. Marker `CONTEXT_MODE_PARTIAL_INSTALL` lets external monitoring grep for the exact failure mode. start.mjs wiring: runs AFTER the existing heal layers (so missing files they can fix get a chance first), BEFORE `import("./server.bundle.mjs")`. On !ok, emits the structured report and exits 2 instead of letting the downstream import surface the opaque error. Skipped under VITEST so the repo's own test invocations against in-tree start.mjs don't fail when running before `npm run build` produces the bundles. The helper is intentionally a separate `.mjs` (not src/util/*.ts) so start.mjs (which ships as raw JS for cold-boot speed) can `await import` it without a TS toolchain. The same `.mjs` is consumable by src/cli.ts for D5's doctor surface. 15-adapter universality: start.mjs is the single MCP entry for every adapter. One change here protects all 15. Reproduce evidence (RED before GREEN): FAIL tests/core/cli.test.ts > start.mjs CLI self-heal > scripts/plugin-cache-integrity.mjs derives expected files from package.json files[] Error: Failed to resolve import "../../scripts/plugin-cache-integrity.mjs" FAIL tests/core/cli.test.ts > start.mjs CLI self-heal > start.mjs invokes assertPluginCacheIntegrity with stderr + exit 2 on failure (Algo-D4) AssertionError: expected start.mjs to contain "plugin-cache-integrity.mjs" FAIL tests/core/cli.test.ts > start.mjs CLI self-heal > scripts/plugin-cache-integrity.mjs ships in npm tarball (package.json files[]) AssertionError: expected [ 'build', 'hooks', 'configs', …(21) ] to include 'scripts/plugin-cache-integrity.mjs' 5 RED tests, all GREEN post-fix. Full suite: 3199 pass / 8 baseline opencode failures (unchanged). typecheck: PASS. RED→GREEN: tests/core/cli.test.ts:1066-1156 |
||
|
|
ceaec16eff |
fix(postinstall): gate hook-normalization heal with isGlobalInstall (#531 fix-of-fix)
CI run 25734987495 (windows-latest) failed on `npm run build` at the
`assert-asymmetric-drift` step with:
asymmetric-drift: FAIL
- .claude-plugin/plugin.json args[0] is
"D:/a/context-mode/context-mode/start.mjs" but must equal
"${CLAUDE_PLUGIN_ROOT}/start.mjs".
Root cause: scripts/postinstall.mjs section 4 ("Hook normalization at
install time (#414)") calls `normalizeHooksOnStartup` which substitutes
`${CLAUDE_PLUGIN_ROOT}` with an absolute pluginRoot path. Section 4's
only existing guard was a `TMPDIR_UPGRADE_RE` check that catches
/ctx-upgrade staging but does NOT catch contributor / CI installs.
On Windows CI the pipeline runs:
1. `npm install` → triggers `npm run postinstall` → section 4 runs
against the cloned repo, rewriting source-tracked
`.claude-plugin/plugin.json` args[0] from the
placeholder to `D:/a/.../start.mjs`.
2. `npm run build` → invokes `scripts/assert-asymmetric-drift.mjs` (the
new Issue #531 invariant). It reads the now-mutated
file, sees drift, exits 1, build fails.
Fix: gate section 4 with `isGlobalInstall()` — the same heuristic
section -1 already uses ("npm_config_global=true" AND no `.git` walking
up the tree). A contributor's `npm install` from a clone (and CI checkouts)
always have `.git` → isGlobalInstall returns false → section 4 skips →
source files stay untouched. Real `npm install -g context-mode` is
unaffected: no `.git` near the cache dir → guard passes → heal runs.
Test coverage:
- New regression test "postinstall.mjs DOES NOT mutate source-tracked
plugin.json when run from a clone (Windows CI regression)" in
tests/scripts/asymmetric-drift-assert.test.ts. The test runs the REAL
postinstall.mjs (not a mock) against a temp clone-like layout with
`.git` and `node_modules` siblings, asserts plugin.json args[0] is
STILL the placeholder. Any future regression that lets section 4
mutate source files surfaces immediately as a vitest failure.
Verified locally:
$ npm run build → all bundles OK + asymmetric-drift OK + exit 0
$ npx vitest run tests/scripts/asymmetric-drift-assert.test.ts
Test Files 1 passed (1)
Tests 8 passed (8)
|
||
|
|
4da170e010 |
fix(ci): assert-asymmetric-drift reads .mcp.json.example after #531 untrack
After the #531 architectural untrack (commit
|
||
|
|
1b872b0bdb | merge: v122/issue-533-conda-python-a99ae59d | ||
|
|
beb72dee6b |
fix(install): postinstall.mjs runs healMcpJsonArgs alongside #523 heal (closes #531 partial — 10 of 10)
Closes the escape-hatch path for users broken by the #253/aea633c bare ./start.mjs regression or by /ctx-upgrade tmpdir leak. When MCP is dead (because .mcp.json is poisoned and Claude Code can't spawn the child) the only recovery path is `npm install -g context-mode` whose postinstall MUST heal both sibling files. Wires Layer 6 heal into the existing #523 per-entry loop so both .mcp.json and .claude-plugin/plugin.json drift heals from a single npm install. Per-call try/catch — one poisoned entry must not block heals on others. Combined stderr summary line mentions both issue numbers so users grep-find the heal in the install transcript. This completes Issue #531 fix slices 2-10. With slice 1 (committed at |
||
|
|
951cd925f6 |
fix(install): CI invariant prevents future .mcp.json / plugin.json asymmetric drift (closes #531 partial — 9 of 10)
Architectural guardrail that prevents the class of bug that caused #531.
The repo ships TWO sibling files carrying MCP server args:
1. .mcp.json (Claude Code reads at plugin load)
2. .claude-plugin/plugin.json (used by Cursor adapter)
v1.0.118 fixed .mcp.json (#411). v1.0.119 fixed plugin.json AND added
self-heal — but ONLY for plugin.json. Asymmetric coverage. Then commit
|
||
|
|
4362fa5376 |
fix(install): healMcpJsonArgs deep module for .mcp.json drift (closes #531 partial — 2-6 of 10)
Asymmetric-heal sibling of healPluginJsonMcpServers (#523). v1.0.119 healed .claude-plugin/plugin.json but missed the sibling .mcp.json — same plugin, same drift class, different file. Detects two drift shapes: 1. Bare relative ./start.mjs (#253/aea633c regression — fresh-install class, the slice 1 commit fixed the source template). 2. Tmpdir-prefixed <...>/context-mode-upgrade-<digits>/start.mjs (mirrors healPluginJsonMcpServers's #523 tmpdir class for /ctx-upgrade tmpdir poisoning). Both rewrite to the literal ${CLAUDE_PLUGIN_ROOT}/start.mjs placeholder Claude Code resolves at load-time. Same regex, same placeholder, same traversal guard as #523. Only difference: target is <pluginRoot>/.mcp.json (flat shape, no .claude-plugin/ subdir) and structure is .mcpServers.<pluginName>.args[]. Slices 2-6 of 10: - Slice 2: rewrites bare relative ./start.mjs - Slice 3: rewrites tmpdir-prefixed paths (POSIX + Windows backslash) - Slice 4: idempotent on healthy placeholder - Slice 5: traversal guard refuses paths outside pluginCacheRoot - Slice 6: preserves unrelated mcpServers entries |
||
|
|
a910c42be3 |
fix(heal): override conda PYTHON for better-sqlite3 build (closes #533)
scripts/heal-better-sqlite3.mjs spawned `npm install better-sqlite3`
and prebuild-install without overriding PYTHON. When a user has
Anaconda/Miniconda's python3 first on PATH (common macOS data-science
setup), node-gyp picked it up via its `python3` PATH fallback and
better-sqlite3's native build died on Node 26 arm64.
node-gyp's PYTHON resolution order (verified against
nodejs/node-gyp main/lib/find-python.js — see `const checks = [...]`):
1. --python CLI flag ← we now set via npm_config_python
2. env.PYTHON ← we now pin to safe interpreter
3. `python3` on PATH ← conda hijacks this slot
4. `python` on PATH
Fix — five interlocking pieces, mapped to PRD slices:
Slice 1: resolveSafePython() — exported helper, dep-injected for unit
test. On darwin returns /usr/bin/python3 when it exists, else null.
Filters /opt/anaconda*, /opt/miniconda*, miniforge, .conda, conda
prefixes. On linux walks PATH and picks first non-conda python3.
Slice 2: package-missing branch now passes a sanitised env to
execFileSync — PYTHON + npm_config_python pinned to safe python,
CONDA_PREFIX / CONDA_DEFAULT_ENV / CONDA_EXE / CONDA_PROMPT_MODIFIER
/ CONDA_SHLVL / CONDA_PYTHON_EXE all deleted, /usr/bin prepended
to PATH on darwin so sub-scripts that shell `python3` unqualified
still resolve to system python.
Slice 3: Layer A spawnSync (prebuild-install fast path) now receives
the same childEnv instead of a bare `{ ...process.env }` spread.
Layer B execSync also receives childEnv.
Slice 4: stderr breadcrumb when conda detected — single line naming
the chosen PYTHON, gated on isCondaActive() so users without conda
see no noise.
Slice 5: new reason code "python-conda-blocked" returned when conda
is active AND no safe python fallback found (rare — stripped-down
Linux images / Docker base images without system python). Lets
/ctx-upgrade render conda-specific remediation in a future patch.
tests/util/heal-better-sqlite3-python.test.ts — 10 tests covering:
- resolveSafePython() exported and behaves on darwin
- conda path filter (4 representative conda layouts rejected)
- null fallback when /usr/bin/python3 absent
- package-missing branch passes env (not bare process.env spread)
- CONDA_* keys stripped
- /usr/bin prepend on darwin
- Layer A spawnSync env wired to safe env
- stderr breadcrumb mentions conda + python path
- python-conda-blocked reason code present
All 10 GREEN. Full util suite (110 tests) passes. Typecheck clean.
Refs: #533
|
||
|
|
1e73a13d19 |
fix(codex): make plugin discoverable via .agents/plugins/marketplace.json (#525)
PR #525 (tedjy971) reported that context-mode never shows up in Codex CLI's `/plugin` listing despite shipping a `.codex-plugin/marketplace.json`. The reported claims were verified against the Codex Rust source in refs/platforms/codex and OpenAI's published docs — all three load-bearing assertions hold: 1. Codex reads `MARKETPLACE_MANIFEST_RELATIVE_PATHS` = `[.agents/plugins/marketplace.json, .claude-plugin/marketplace.json]` (codex-rs/core-plugins/src/marketplace.rs:21). `.codex-plugin/ marketplace.json` is NOT in this list — Codex never opens it. 2. The local-plugin source `path` must be `./<subdir>`, not `./`. Codex's `resolve_local_plugin_source_path` (marketplace.rs:502-518) does `path.strip_prefix("./")` then rejects empty results with `"local plugin source path must not be empty"`. Our shipped `.claude-plugin/marketplace.json` uses `source: "./"`, which hits this rejection. The error is swallowed silently at marketplace.rs: 446-452 via `warn!(... skipping marketplace plugin that failed to resolve)`, so `codex plugin marketplace add` succeeds with exit 0 but the plugins vec is empty and the user sees nothing in /plugin. 3. `${CODEX_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_ROOT}` placeholders are NOT interpolated by Codex (upstream openai/codex#19582 OPEN). Grep of codex-rs/core-plugins/src/ confirms zero `interpolat*` / `expand_env*` / `envsubst*` logic in non-test source code. These were also corroborated by OpenAI's own docs at https://developers.openai.com/codex/plugins/build which spell out: - "a repo marketplace at $REPO_ROOT/.agents/plugins/marketplace.json" - "source.path points to that plugin directory with a `./`-prefixed relative path" (example: `./plugins/my-plugin`) - "Only plugin.json belongs in .codex-plugin/" End-to-end verification with Codex CLI v0.130.0: $ codex plugin marketplace add /path/to/context-mode Added marketplace `context-mode`. No silent-drop warning emitted by the warn! path now that source.path resolves to a real plugin tree. Changes: 1. Add .agents/plugins/marketplace.json with canonical schema: { name, interface: { displayName }, plugins: [{ name, source: { source: "local", path: "./plugins/context-mode" }, policy, category }] } Matches the Rust serde shape at marketplace.rs:694-744 exactly. 2. Add plugins/context-mode symlink → repo root, so Codex's `resolve_local_plugin_source_path` lands on a directory that contains `.codex-plugin/plugin.json` (the per-plugin manifest path Codex's load_plugin_manifest expects). 3. Delete .codex-plugin/marketplace.json (dead — Codex never reads it, keeping it ships dead bytes and misleads contributors). 4. Remove .codex-plugin/marketplace.json from version-sync.mjs targets and from the `version` lifecycle git-add list. The Codex marketplace schema has no top-level `version` field per the Rust serde struct, so the new .agents/plugins/marketplace.json doesn't need syncing. Per-plugin version still flows through .codex-plugin/plugin.json which remains in the targets list. 5. Add tests/codex/marketplace-layout.test.ts (6 tests) that mirror Codex's exact discovery logic — strip_prefix("./"), non-empty check, plugin.json presence, placeholder absence — so future drift produces a deterministic local failure long before users hit it. 6. Update tests/plugins/codex-manifest.test.ts and tests/scripts/ version-sync.test.ts to reflect the deletion (with comments pointing at the Rust line numbers for future maintainers). Why we shipped our own fix instead of merging tedjy971's PR #525: Same end-state, more rigor — full Rust-source citations, mirror-the- deserializer tests, e2e verification with the v0.130.0 CLI. Their analysis pointed us at the right problem; this commit gives the project a durable test contract so a regression can't slip past CI silently like the original bug did. Credit: tedjy971's PR #525 surfaced the issue and the canonical layout. |
||
|
|
13d134270c |
fix(ctx-upgrade): stop baking tmpdir path into hooks.json (#528, Windows hotfix)
Fifth heal in the post-/ctx-upgrade cascade family (after v1.0.114 enabledPlugins, v1.0.116 settings.json, v1.0.119 plugin.json mcpServers.args). `/ctx-upgrade` was poisoning `hooks/hooks.json` with the upgrade tmpdir's absolute path via `scripts/postinstall.mjs` calling `normalizeHooksOnStartup({pluginRoot: pkgRoot})` where pkgRoot was the tmpdir. After tmpdir cleanup, every hook fired MODULE_NOT_FOUND on Windows.
Fix:
- `scripts/postinstall.mjs` — skip `normalizeHooksOnStartup` when pkgRoot matches the `context-mode-upgrade-<digits>` regex
- `src/cli.ts` — after the in-place cpSync, call `normalizeHooksOnStartup` against the REAL plugin dir; also self-heals legacy poisoned configs
Reviewed by 5 parallel architect-level agents:
- Claim verifier reproduced the bug locally (Windows-only via `platform !== 'win32'` guard in normalize-hooks.mjs:149)
- Solution correctness: regex SAFE across 15 adversarial paths (macOS/Linux/Windows tmpdir conventions all covered)
- Windows specialist: regex bulletproof, heal sequence sound
- Architect: 5th heal correctly placed, wire complete (boot+postinstall+upgrade)
- QA: test slice 7b is behavioral (real spawn, real script). Coverage of cli.ts heal + legacy self-heal will be added in a follow-up.
Thanks @asozyurt for the staff-grade diagnosis: tracing the chain from cli.ts:771 → postinstall normalize → cpSync poison flow, file:line citations, and reproducing on Windows 11 manually.
|
||
|
|
e9a7e69629 |
fix(assert-bundle): Windows entry-point detection (closes #525 windows ci)
CI run 25655545561 windows-latest failed on
tests/scripts/assert-bundle.test.ts > exits 0 on a clean fixture bundle
tests/scripts/assert-bundle.test.ts > exits 1 when given polluted fixture
with "expected '' to match /OK/" and "expected +0 to be 1" — assertions
that only fire if the script produces no output and exits 0 by default.
Root cause: the direct-invocation check at the bottom of the script used
string equality between two values that diverge on Windows:
import.meta.url = "file:///C:/path/to/assert-bundle.mjs"
`file://${process.argv[1]}` = "file://C:\\path\\to\\assert-bundle.mjs"
The first has triple-slash + forward separators (URL form). The second
has double-slash + backslashes (template-literal of the OS path). They
never compare equal on Windows, the fallback `endsWith` likewise never
matches (URL has `/`, argv has `\`), so `isDirectInvocation` was always
false → main() never ran → script exited 0 silently.
The G3 invariant check (`npm run assert-bundle`) was therefore a no-op
on the windows-latest runner — bundles could ship polluted with the
`Dynamic require of` shim and the guardrail wouldn't catch it. This
also explained why the test "current production bundles pass the
assert-bundle clean check" passed-by-accident on Windows: exit 0 from
a silent no-op satisfies `expect(r.status).toBe(0)`.
Fix: use `pathToFileURL(process.argv[1]).href` so the entry-point
comparison is OS-agnostic. Both sides are now normalized to the
canonical `file:///C:/...` form on Windows and `file:///...` on POSIX.
Verified locally on macOS:
$ npm run bundle && npx vitest run tests/scripts/assert-bundle.test.ts
Test Files 1 passed (1)
Tests 4 passed (4)
Bundles are intentionally not rebuilt here — CI step `npm run bundle`
regenerates them from source on every run.
|
||
|
|
675e63cc24 |
chore(ci): assert-bundle catches backtick + whitespace evasions
Review surfaced two evasion gaps in the G3 invariant regex: 1. Template-literal form: require(`node:fs`) slipped through 2. Whitespace expansion: require ( "node:fs" ) slipped through Extend both patterns to allow optional whitespace around require/__require and the parenthesis, plus accept backtick (`) as a valid quote character. |
||
|
|
d3574d564e | merge: v119/bundle-assert-g3 — post-build invariant CI assert (G3 architectural guardrail) | ||
|
|
46ac5c6b24 | merge: v119/pr-512-codex-marketplace — Codex marketplace + version-sync targets (extends PR #512 by @tedjy971) | ||
|
|
94d72c1e59 | merge: v119/issue-523-heal-layer-5 — healPluginJsonMcpServers + Layer 5b boot heal (closes #523) | ||
|
|
49d1a8ab42 |
fix(install): start.mjs + postinstall wire Layer 5b plugin.json heal (closes #523 partial — 8 of 8)
Slice 8 — escape hatch for already-broken users. Slice 7 prevents the
bug going forward (cli.ts upgrade() asserts pre-success), but anyone
already poisoned by v1.0.118's /ctx-upgrade has a dead MCP server and
no /ctx-upgrade to recover with. Two recovery paths:
1. start.mjs HEAL block: every MCP boot, after HEAL 3 + HEAL 4, also
iterate installed_plugins.json's plugins["context-mode@context-mode"]
entries and run healPluginJsonMcpServers on each entry's installPath.
The next time Claude Code spawns the plugin, args[0] is healed and
subsequent boots work.
2. scripts/postinstall.mjs: same iteration after the v1.0.114 +
v1.0.116 heals. Triggered by `npm install -g context-mode@1.0.119`
— the universal escape hatch that runs even when MCP is dead.
Per-entry try/catch wraps each heal call so one poisoned entry cannot
block heals on the others. Outer try/catch around the dynamic import
preserves the "never block MCP boot" contract.
5 vertical TDD assertions in tests/util/start-mjs-self-heal.test.ts:
- imports healPluginJsonMcpServers from the shared module
- heal call lives inside HEAL 3+4 try-block (co-located, single import)
- iterates ALL cache entries via installPath (multi-version support)
- 3+ try/catch layers (defensive posture)
- postinstall.mjs also wires Layer 5b (escape hatch)
Cumulative defense (v1.0.113→v1.0.119):
- v1.0.113: start.mjs no-poison + getProjectDir env-chain rejection
- v1.0.114: HEAL 3+4 + ctx-upgrade asserts (installed_plugins.json)
- v1.0.115: transcript heuristic
- v1.0.116: HEAL 4 targets settings.json (the file CC actually reads)
- v1.0.119: HEAL 5b targets plugin.json mcpServers args (Issue #523)
|
||
|
|
308a80f9a3 |
fix(codex): add .codex-plugin/* to version-sync targets (extends PR #512 by @tedjy971)
Without this, every release bump would drift `.codex-plugin/plugin.json` and `.codex-plugin/marketplace.json` further out of sync with the canonical `package.json:version`. Same hazard previously hit `.cursor-plugin/plugin.json` (stuck at v1.0.111 vs current v1.0.118) because it was missing from BOTH the targets[] in version-sync.mjs and the npm `version` lifecycle `git add` list. Two-part fix: - `scripts/version-sync.mjs` → append the two Codex manifests to `targets[]` (so the rewrite touches them). - `package.json` → extend the `version` script's `git add` list to include the two Codex manifests AND `.cursor-plugin/plugin.json` (the cursor manifest had the same defect; without it staged, the rewrite is silently discarded by the npm `version` commit). End-to-end test in tests/scripts/version-sync.test.ts copies all manifests into a scratch repo with a synthetic version, runs the script, and asserts every (version | metadata.version | plugins[].version) field gets rewritten — catches future targets[] drift automatically. |
||
|
|
550ca73204 |
fix(install): healPluginJsonMcpServers detects tmpdir-prefixed args[0] (closes #523 partial — 1 of 8)
Issue #523: /ctx-upgrade in v1.0.118 wrote .mcp.json with the ${CLAUDE_PLUGIN_ROOT} placeholder (#411 fix) but did NOT touch .claude-plugin/plugin.json. On Windows + Claude Code, normalize-hooks rewrites that file's mcpServers.args[0] to an absolute path. When pluginRoot resolves to the upgrade tmpdir, the resulting plugin.json carries <tmpdir>/context-mode-upgrade-<epoch>/start.mjs. After tmpdir cleanup, MCP fails to spawn with ENOENT — and the user has no /ctx-upgrade escape hatch. Tracer-bullet slice — Layer 5 heal: - New healPluginJsonMcpServers() in scripts/heal-installed-plugins.mjs - Detects tmpdir-prefixed args[0] (epoch-pattern, OS-agnostic regex /[/\\]context-mode-upgrade-\d+[/\\]/) ending in start.mjs - Rewrites to literal ${CLAUDE_PLUGIN_ROOT}/start.mjs placeholder - Path-traversal guard mirrors HEAL 3 (refuses outside cache root) - Best-effort posture, never throws — same contract as healInstalledPlugins Sibling of #411 — closes the gap that fix left in plugin.json. |
||
|
|
0b5717c563 |
fix(heal): install better-sqlite3 when package directory is missing (closes #514 partial — 2 of 4)
scripts/heal-better-sqlite3.mjs treated the package-missing branch as a
no-op — it returned {healed:false, reason:'package-missing'} and trusted
ensure-deps's install branch to recover. On Node 26, ensure-deps's npm
install also silently skipped the package because it lived under
optionalDependencies. Result: both healers fell through to a manual
remediation hint that /ctx-upgrade never surfaced.
Take ownership of the branch: when node_modules/better-sqlite3 does not
exist, run `npm install better-sqlite3 --no-optional --no-save
--no-audit --no-fund` via execFileSync with a 180s timeout. --no-optional
defends against future regressions if anyone reverts package.json. On
success, fall through into the existing prebuild-install / npm install /
stderr-advice flow so binding-missing recovery still runs.
tests/util/heal-better-sqlite3.test.ts — guards no-early-return,
--no-optional usage, execFileSync+timeout, and continuation into the
binding-missing path.
Refs: #514
|
||
|
|
61f85398b1 |
chore(ci): slice 1 — assert-bundle script detects 'Dynamic require of' shim
G3 guardrail (Issue #511 class). Adds scripts/assert-bundle.mjs which scans bundle files for the esbuild throwing-require shim and exits 1 if any forbidden pattern is matched. Tracer-bullet RED→GREEN: fixture bundle containing the shim string is correctly rejected. |
||
|
|
4337f472aa |
fix(install): heal settings.json.enabledPlugins (v1.0.116 hotfix)
v1.0.114's heal targeted installed_plugins.json.enabledPlugins, which is what we control. But Claude Code's plugin loader actually reads the truth from ~/.claude/settings.json.enabledPlugins. After every /ctx-upgrade, Claude Code's plugin manager seems to clear that key (likely on version-mismatch detection), so the plugin appears disabled and /reload-plugins returns 0 plugins. v1.0.114 self-heal silently fixed the wrong file. Fix: - New healSettingsEnabledPlugins() in scripts/heal-installed-plugins.mjs. - Wired into start.mjs HEAL 4 (every MCP boot) and scripts/postinstall.mjs (every npm install -g). - Respects explicit user opt-out: if the key is `false`, leaves it alone. - Idempotent: no rewrite when key is already true. Tests: 5 new vertical TDD slices in tests/util/heal-installed-plugins.test.ts: - creates section + adds key when settings is missing the section - adds key when section exists but ours is missing - idempotent — no rewrite when already true - respects user opt-out (false stays false) - silent skip when settings.json doesn't exist 2,821 pass / 8 baseline opencode failures / 24 skipped. Typecheck clean. Cumulative defense (v1.0.113→v1.0.114→v1.0.115→v1.0.116): - v1.0.113: start.mjs no-poison + getProjectDir env-chain rejection - v1.0.114: HEAL 3+4 + ctx-upgrade asserts (wrong file) - v1.0.115: transcript heuristic - v1.0.116: HEAL 4 finally targets the RIGHT file (settings.json) |
||
|
|
8c045f96ed |
fix(install): npm postinstall self-heal for poisoned installed_plugins.json (v1.0.114)
v1.0.113's /ctx-upgrade poisoned ~/.claude/plugins/installed_plugins.json
in two ways: (a) per-entry version drifted from the cache directory's
plugin.json version, and (b) the top-level enabledPlugins[<key>] was
emptied. Claude Code's plugin loader then refuses to load context-mode,
killing MCP — and with MCP gone the user can no longer run /ctx-upgrade
to recover. The escape hatch is `npm install -g context-mode@1.0.114`,
which executes regardless of plugin-loader state.
Adds a shared heal module (scripts/heal-installed-plugins.mjs) that:
- HEAL 3: rewrites entry.version from each cache dir's plugin.json
- HEAL 4: ensures enabledPlugins[<key>] is set when missing/empty
- returns a result object — never throws, best-effort posture
Wires it into scripts/postinstall.mjs behind an isGlobalInstall() guard
(npm_config_global=true AND no nearby .git) so contributor `npm install`
runs do not rewrite their HOME registry. Emits exactly one ASCII stderr
summary line per run: healed / no-heal-needed / no-Claude-Code-registry.
Coordination: this module is the single source of truth; start.mjs HEAL
3+4 should import from `./scripts/heal-installed-plugins.mjs` so install-
time and runtime heals stay aligned.
Tests:
- tests/util/heal-installed-plugins.test.ts (9): HEAL 3 sync, HEAL 4
create/rewrite/idempotent, no-registry skip, healthy no-op, path-
traversal guard, native sep, package.json files[] guard.
- tests/util/postinstall-heal.test.ts (4): integration via spawnSync
against a staged npm-install layout — non-global skip, poisoned
registry repair, no-Claude-Code silent OK, already-healthy no-op.
Full suite: 2824 tests, 8 failed (pre-existing opencode baseline),
2787 passed, 24 skipped. +13 tests, 0 regressions.
|
||
|
|
43c63cb434 | Merge remote-tracking branch 'origin/next' into v108-fixes | ||
|
|
fe421d7816 |
feat(scripts): tsx production-proof for ctx_stats narrative renderer
Slice 7 — invokes the EXACT formatReport function the production ctx_stats handler calls (src/server.ts:2636) with a fixture mirroring the Mert-approved demo (67 days × 128 conversations × 356 MB lifetime, 1277 captures × 12 days × 1552 KB rescue conversation, 22 preferences across 6 projects) and prints the rendered output verbatim. Output matches the target line-for-line including: - Opener: 'Across 67 days you ran 128 conversations in Claude Code.' - Section 1: started 28 Apr 2026 at 12:16 (Europe/Istanbul) + /compact rescue at 9 May 2026 at 20:54 (Europe/Istanbul) + horizontal timeline with rescue ◆ at column 50, peak █ at column 28 - Section 2: 1,277 things — all 18 categories rendered, no truncation - Section 3: receipt rows for this conversation + all real work - Section 4: $1399.73 on Opus 4 + 70 months Cursor / 7.0 months Claude Max / 19 weekends + ~$13997 team scale + ~$76254/year - Section 5: 22 preferences picked up across 6 projects - Footer: locale en-TR · timezone Europe/Istanbul · v1.0.111 Run via: npx tsx scripts/prove-narrative-render.ts |
||
|
|
b465acd834 |
docs(omp): drop hardcoded version from install guide + prune redundant manifest field
The previous manual install path pasted a literal `"version": "1.0.111"`
into a JSON snippet for omp-plugins.lock.json. That number drifts
silently on every release — anyone reading the README a week from
now would copy a stale version into their lock file.
Verified upstream that the snippet was unnecessary in the first
place. The plugin loader at refs/platforms/oh-my-pi/packages/
coding-agent/src/extensibility/plugins/loader.ts:89-94 only consults
the lock file when a plugin is explicitly disabled:
const runtimeState = runtimeConfig.plugins[name];
if (runtimeState && !runtimeState.enabled) continue;
Plugins missing from the lock file load with default-enabled state.
So the manual install collapses to two commands: `cd ~/.omp/plugins`
+ `bun add context-mode`, then restart. No JSON to edit, no version
to pin.
Same logic eliminates the `omp.version` field we had been carrying in
the root package.json. The upstream loader stamps
`manifest.version = pluginPkg.version` from the top-level
package.json:version on every load (loader.ts:87), so duplicating it
inside the omp block adds a drift surface and zero signal. The
matching `pi` block follows the same convention, so consistent.
Drops the corresponding omp.version sync code from
scripts/version-sync.mjs — it can no longer drift if the field
doesn't exist.
|
||
|
|
2ddae394c4 |
feat(omp): plugin path with native hook enforcement (HookAPI tool_call/tool_result/session_start/session_before_compact)
Promotes OMP from MCP-only delivery to a proper plugin. `omp plugin
install context-mode` now wires programmatic enforcement equivalent to
Claude Code's PreToolUse/PostToolUse/PreCompact/SessionStart pipeline.
Verified end-to-end against the upstream OMP source cloned to
refs/platforms/oh-my-pi @ v3.20.1 (no LLM trust, every claim
file:line cited):
- Manifest format: `omp` or `pi` field on root package.json
Source: refs/.../extensibility/plugins/loader.ts:75
`const manifest = pluginPkg.omp || pluginPkg.pi;`
+ line 82: `manifest.version = pluginPkg.version;` (loader stamps
version from top-level pkg.version on load — explicit
`omp.version` is belt-and-suspenders, kept synced by
scripts/version-sync.mjs).
- Install command: `omp plugin install <pkg>` runs
`bun install <pkg>` inside ~/.omp/plugins per
refs/.../extensibility/plugins/manager.ts:158, then reads
`~/.omp/plugins/node_modules/<pkg>/package.json` for the manifest.
- HookFactory contract: `(pi: HookAPI) => void` per
refs/.../extensibility/hooks/types.ts:809.
- Block return shape: `{ block?: boolean; reason?: string }` per
refs/.../extensibility/hooks/types.ts:566.
- Event payloads:
- ToolCallEvent (refs/.../hooks/types.ts:448): {toolName, toolCallId, input}
- ToolResultEvent (refs/.../hooks/types.ts:461 onward): {toolName, toolCallId, input, content[], isError}
- Example reference: refs/.../examples/hooks/permission-gate.ts.
What the plugin actually does:
- tool_call: hard-blocks bash containing curl/wget/inline-fetch
(`requests.get`, `http.get`, `Invoke-WebRequest`, etc.) — same
pattern set as the Pi extension.
- tool_result: feeds OMP-shaped events through the existing
extractEvents pipeline → SessionDB at ~/.omp/context-mode/.
- session_start: derives a stable 16-hex session id from
sessionManager.getSessionFile() (or wall-clock fallback), runs
7-day cleanup.
- session_before_compact: persists a buildResumeSnapshot output via
upsertResume + increments compact_count for resume-on-restart.
Reference parity:
- Mirrors src/adapters/pi/extension.ts shape closely. OMP differs in
two ways that justify a dedicated file:
1. Storage at ~/.omp/context-mode/ via OMPAdapter (not ~/.pi/)
2. OMP has native MCP via mcp.json — the Pi extension's
mcp-bridge.ts is dead weight under OMP and is intentionally
omitted here.
- Mirrors src/adapters/openclaw/plugin.ts integration shape (root
package.json field → built JS entry).
Smoke test (run locally before commit):
- pkg.omp.hooks resolves to build/adapters/omp/plugin.js ✓
- default export is a function ✓
- 4 handlers register: session_start, tool_call, tool_result,
session_before_compact ✓
- tool_call({toolName: 'bash', input: {command: 'curl ...'}}) →
{block: true, reason: '...'} ✓
Tests: tests/adapters/omp-plugin.test.ts adds 17 cases across 4 TDD
slices (routing, extraction, session lifecycle, resume snapshot). All
green. Full vitest run: 2642 passed, 20 skipped, 0 failed.
scripts/version-sync.mjs now also stamps package.json:omp.version
when running on `npm version` lifecycle so OMP manifest version
never drifts from top-level pkg.version (verified by simulating a
stale 0.0.0 value and watching it correct to current).
README updated:
- OMP install section reordered: plugin path is now primary, with
upstream file:line citations for the loader and block contract;
MCP-only path retained as the alternative.
- Hook coverage table (lines ~1024-1031): OMP rows promoted from
"--" to ✓ (via tool_call event), etc.
- Platform compatibility table: OMP PreToolUse/PostToolUse/
SessionStart/PreCompact/CanBlockTools all marked Plugin.
- Routing-enforcement note: OMP moved from non-hook list to
hook-capable list.
- All "OMP MCP-only / no hook integration" prose paragraphs
rewritten.
|
||
|
|
7b86ee5a20 |
feat(cursor): add Marketplace plugin packaging (#489)
* feat(cursor): add Marketplace plugin packaging
Mirror the Claude Code plugin layout for Cursor's plugin marketplace:
- .cursor-plugin/plugin.json: manifest pointing at ./configs/cursor/context-mode.mdc, ./skills/, ./hooks/cursor/hooks.json, and an MCP server entry running 'npx -y context-mode'.
- hooks/cursor/hooks.json: registers preToolUse, postToolUse, sessionStart, afterAgentResponse, and stop, all dispatched through 'npx -y context-mode hook cursor <event>' so users do not need a local clone.
- src/adapters/cursor/index.ts: doctor now detects plugin installs under ~/.cursor/plugins/{local,cache} and warns when both the plugin and a native .cursor/hooks.json register context-mode hooks.
- scripts/version-sync.mjs: keeps .cursor-plugin/plugin.json in lockstep with package.json.
- README.md, docs/platform-support.md: document the Marketplace install path alongside the existing manual install.
Refs #485
* feat(cursor): add plugin README + drop non-schema displayName field
- Add .cursor-plugin/README.md so the Marketplace tile has a dedicated landing page (project root README is unchanged).
- Remove 'displayName' from .cursor-plugin/plugin.json: the field is not in Cursor's plugin manifest schema (https://cursor.com/docs/reference/plugins) and would be flagged by the validator.
Validated all manifest keys against the official schema; no other extra fields. Cursor adapter test suite: 50/50 pass.
* feat(cursor): add Marketplace logo
Adds .cursor-plugin/assets/logo.png and references it via the manifest 'logo' field. Cursor resolves relative paths to raw.githubusercontent.com URLs at the commit SHA, so the Marketplace tile renders the snowflake icon directly from the repo.
* docs(cursor): add local-install quickstart for testers
Document the robocopy/symlink workflow so reviewers (and early adopters) can try the plugin from the repo before Marketplace acceptance. Calls out the Windows symlink limitation explicitly so testers do not waste time debugging mklink.
* docs(cursor): mark Marketplace plugin as work-in-progress until review
Per maintainer feedback: until Cursor's review team lists the plugin, the README needs an explicit 'work in progress' notice plus copy-pasteable local-install commands for both Windows (robocopy) and macOS/Linux (ln -s). Calls out the Windows symlink limitation directly so testers do not waste time debugging mklink.
Refs #485, #489
---------
Co-authored-by: Maxwell_sun <Maxwell_sun@noreply.gitcode.com>
Co-authored-by: Mert Köseoğlu <bm.ksglu@gmail.com>
|
||
|
|
25a8f844e7 |
feat(ci): tier-2 E2E smoke scaffolding (#477) (#478)
* feat(ci): tier-2 E2E smoke scaffolding (#477) Tier-1 mock harness in tests/pi-extension.test.ts already pins the canonical ctx_* tool set and the #426 wiring, so registration regressions are caught on every PR for free. What that suite cannot catch is whether a real LLM, running through a real host binary, actually invokes ctx_search / ctx_execute / ctx_index and reports a positive token saving via ctx-stats. That gap is what mystilleef has been QA-ing manually each release. This commit lands the scaffolding for the tier-2 workflow proposed in issue #477: - .github/workflows/tier2-e2e-smoke.yml: workflow_dispatch only matrix over pi / claude-code / opencode, with concurrency cancel, 15-minute timeout, max-tokens cap, and a header comment pointing maintainers at the Anthropic console for monthly spend caps. Cron is intentionally commented out until all three hosts go green twice in a row. - scripts/tier2-smoke/run-pi-smoke.sh: boots Pi headless on a fixture prompt that forces ctx_index + ctx_search + ctx_execute usage, then captures the structured ctx-stats payload. - scripts/tier2-smoke/assert-stats.mjs: host-agnostic assertion CLI used by every host in the matrix. Fails if any required ctx_* tool was not invoked, if tokens_saved is not positive, or if any tool reported an error. - scripts/tier2-smoke/fixtures/search-corpus.txt: the prompt itself, pinned in-repo so a future change to the fixture is reviewable. - tests/tier2-smoke-assert.test.ts: black-box vitest coverage for assert-stats.mjs so regressions in the gating logic are caught by tier-1 CI before any tier-2 run is even scheduled. The claude-code and opencode jobs are placeholders for now — they exit 0 with a pointer to the issue. Pi lands first; the other two follow in separate PRs once Pi is stable. Refs #477 #426 * feat(ci): pin tier-2 to Haiku 4.5 + beef fixture + clarify CLI fallback Three follow-ups on PR #478 review: 1. Pin model to claude-haiku-4-5-20251001 (workflow env + forwarded as ANTHROPIC_MODEL/PI_MODEL to the smoke runner). Same Anthropic family as Sonnet/Opus so tool-calling behavior tracks what real users hit, but ~5-10x cheaper per smoke run — keeps weekly cron tenable without degrading the signal. Header comment explicitly warns against swapping for Gemini/DeepSeek: tier-2 must test the model that actually ships, otherwise a passing smoke can mask a tool-routing regression in Claude. 2. Beef up scripts/tier2-smoke/fixtures/search-corpus.txt. Old fixture was ~6 lines, which made tokens_saved>0 flaky for reasons unrelated to a real bug (tiny corpus = no measurable savings). New fixture indexes README + CLAUDE.md + src/ + tests/ + workflow files, runs 7 batched ctx_search queries, and forces a ctx_execute pass that emits structured JSON. Big enough that ctx-stats reports a clearly positive saving. 3. Add explicit comment on the run-pi-smoke.sh fallback explaining that it only works because Pi and the bundled CLI share state under $HOME/.context-mode/. If a future Pi release sandboxes per-extension state, this fallback would silently return zeros — flagged so a future reader either keeps state shared via CTX_STATE_DIR or deletes the fallback and fails hard instead of masking. Refs #477 #478 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c805410878 |
fix(pi): bridge MCP tools into pi.registerTool() so the LLM can call them (#426) (#472)
* fix(pi): bridge MCP tools into pi.registerTool() so the LLM can call them (#426) Pi 0.73.x has no native MCP support — its README is explicit: > No MCP. Build CLI tools with READMEs (see Skills), or build an > extension that adds MCP support. Without a bridge inside the context-mode Pi extension, the routing block tells the LLM to call `ctx_execute` / `ctx_search` / etc. but those tools never enter Pi's tool list and the LLM cannot reach them. The reporter measured 18 sessions over 2 days: ~2,500 tokens of system-prompt overhead per window, 0 actual ctx_* calls, 447 events recorded but never retrieved. Net ROI on Pi was negative. This adds a stdio JSON-RPC client (`MCPStdioClient`) plus a thin bootstrap (`bootstrapMCPTools`) that: - spawns `server.bundle.mjs` as a long-lived MCP child, - performs the standard MCP handshake (initialize → notifications/initialized), - lists tools once via `tools/list`, and - registers each tool through `pi.registerTool({ name, label, description, parameters, execute })` so the LLM sees the canonical bare names (matching what hooks/core/tool-naming.mjs emits for Pi). Each Pi `execute()` callback forwards into the MCP child via `tools/call`. Errors are translated to `throw` (Pi's contract for "tool failed") so the LLM sees the MCP server's diagnostic text. Lifecycle: - Bridge bootstrap is fire-and-forget at extension load — the rest of the extension (session capture, hooks, slash commands) is not blocked by spawn / handshake latency. - `session_shutdown` terminates the child via SIGTERM. - A missing `server.bundle.mjs` or any spawn / handshake error is surfaced once on stderr, then the extension keeps running with only the existing hooks + commands. Defense-in-depth so the bridge can never break Pi sessions for users with broken installs. ## Why a JSON Schema parameters object instead of TypeBox MCP `tools/list` returns JSON Schema. Pi's parameter validator accepts JSON Schema directly (TypeBox just produces JSON Schema with extra Symbol metadata for type inference). Passing the schema through unchanged avoids a runtime translation pass and keeps the bridge a true thin layer over the MCP protocol — what works in Claude Code, Gemini CLI, and the other adapters now also works in Pi. ## No new runtime dependencies Pure `node:child_process` + `node:path`. The `@earendil-works/pi-*` packages are NOT pulled in as build deps — `pi` is typed structurally as `any` (matching the existing src/pi-extension.ts style) and the bridge only touches the documented `pi.registerTool()` shape. ## Tests Added two new `describe` blocks in `tests/pi-extension.test.ts`: 1. `MCPStdioClient` (5 tests) — wire-protocol contract pinned with fake stdio servers: id-matched responses, concurrent in-flight requests with out-of-order delivery, child-exit cancellation, timeout, non-JSON noise tolerance. 2. `bootstrapMCPTools` (2 integration tests) — spawn the real `start.mjs` MCP server, assert that the canonical ctx_* set (`ctx_execute`, `ctx_execute_file`, `ctx_search`, `ctx_index`, `ctx_batch_execute`, `ctx_fetch_and_index`, `ctx_doctor`, `ctx_stats`, `ctx_purge`) is registered, and round-trip `ctx_index` through `tools/call` to confirm execute() forwards args and returns text. ## Test plan - [x] `npm run build` - [x] `npm run typecheck` clean - [x] `npm test` — 73 files, 2406 pass / 25 skipped / 0 fail - [x] `npx vitest run tests/pi-extension.test.ts` — 44 pass (37 pre-existing + 7 new for the bridge) - [x] see-real-bug repro: `pi.registerTool` count = 0 in installed binary (`/home/$USER/.nvm/.../context-mode/build/pi-extension.js`) on `next` @ |