@mxalbert/context-mode 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +31 -0
- package/.codex-plugin/hooks.json +65 -0
- package/.codex-plugin/mcp.json +13 -0
- package/.codex-plugin/plugin.json +31 -0
- package/.openclaw-plugin/index.ts +12 -0
- package/.openclaw-plugin/openclaw.plugin.json +38 -0
- package/.openclaw-plugin/package.json +28 -0
- package/LICENSE +94 -0
- package/README.md +1619 -0
- package/bin/statusline.mjs +378 -0
- package/build/adapters/antigravity/index.d.ts +51 -0
- package/build/adapters/antigravity/index.js +169 -0
- package/build/adapters/antigravity-cli/index.d.ts +51 -0
- package/build/adapters/antigravity-cli/index.js +342 -0
- package/build/adapters/base.d.ts +92 -0
- package/build/adapters/base.js +135 -0
- package/build/adapters/claude-code/config.d.ts +8 -0
- package/build/adapters/claude-code/config.js +8 -0
- package/build/adapters/claude-code/hooks.d.ts +106 -0
- package/build/adapters/claude-code/hooks.js +183 -0
- package/build/adapters/claude-code/index.d.ts +80 -0
- package/build/adapters/claude-code/index.js +569 -0
- package/build/adapters/claude-code-base.d.ts +49 -0
- package/build/adapters/claude-code-base.js +113 -0
- package/build/adapters/client-map.d.ts +10 -0
- package/build/adapters/client-map.js +42 -0
- package/build/adapters/codex/config.d.ts +8 -0
- package/build/adapters/codex/config.js +8 -0
- package/build/adapters/codex/hooks.d.ts +55 -0
- package/build/adapters/codex/hooks.js +64 -0
- package/build/adapters/codex/index.d.ts +87 -0
- package/build/adapters/codex/index.js +995 -0
- package/build/adapters/codex/paths.d.ts +1 -0
- package/build/adapters/codex/paths.js +12 -0
- package/build/adapters/codex/usage.d.ts +107 -0
- package/build/adapters/codex/usage.js +227 -0
- package/build/adapters/copilot-base.d.ts +94 -0
- package/build/adapters/copilot-base.js +297 -0
- package/build/adapters/copilot-cli/hooks.d.ts +33 -0
- package/build/adapters/copilot-cli/hooks.js +64 -0
- package/build/adapters/copilot-cli/index.d.ts +48 -0
- package/build/adapters/copilot-cli/index.js +341 -0
- package/build/adapters/cursor/config.d.ts +4 -0
- package/build/adapters/cursor/config.js +4 -0
- package/build/adapters/cursor/hooks.d.ts +55 -0
- package/build/adapters/cursor/hooks.js +82 -0
- package/build/adapters/cursor/index.d.ts +64 -0
- package/build/adapters/cursor/index.js +517 -0
- package/build/adapters/detect.d.ts +136 -0
- package/build/adapters/detect.js +623 -0
- package/build/adapters/gemini-cli/config.d.ts +8 -0
- package/build/adapters/gemini-cli/config.js +8 -0
- package/build/adapters/gemini-cli/hooks.d.ts +83 -0
- package/build/adapters/gemini-cli/hooks.js +115 -0
- package/build/adapters/gemini-cli/index.d.ts +80 -0
- package/build/adapters/gemini-cli/index.js +483 -0
- package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
- package/build/adapters/jetbrains-copilot/config.js +8 -0
- package/build/adapters/jetbrains-copilot/hooks.d.ts +59 -0
- package/build/adapters/jetbrains-copilot/hooks.js +87 -0
- package/build/adapters/jetbrains-copilot/index.d.ts +31 -0
- package/build/adapters/jetbrains-copilot/index.js +131 -0
- package/build/adapters/kimi/config.d.ts +8 -0
- package/build/adapters/kimi/config.js +8 -0
- package/build/adapters/kimi/hooks.d.ts +28 -0
- package/build/adapters/kimi/hooks.js +34 -0
- package/build/adapters/kimi/index.d.ts +66 -0
- package/build/adapters/kimi/index.js +537 -0
- package/build/adapters/kimi/paths.d.ts +21 -0
- package/build/adapters/kimi/paths.js +52 -0
- package/build/adapters/kimi/usage.d.ts +82 -0
- package/build/adapters/kimi/usage.js +217 -0
- package/build/adapters/kiro/hooks.d.ts +61 -0
- package/build/adapters/kiro/hooks.js +94 -0
- package/build/adapters/kiro/index.d.ts +61 -0
- package/build/adapters/kiro/index.js +301 -0
- package/build/adapters/omp/index.d.ts +65 -0
- package/build/adapters/omp/index.js +182 -0
- package/build/adapters/omp/plugin.d.ts +81 -0
- package/build/adapters/omp/plugin.js +331 -0
- package/build/adapters/omp/usage.d.ts +49 -0
- package/build/adapters/omp/usage.js +110 -0
- package/build/adapters/openclaw/config.d.ts +8 -0
- package/build/adapters/openclaw/config.js +8 -0
- package/build/adapters/openclaw/hooks.d.ts +50 -0
- package/build/adapters/openclaw/hooks.js +61 -0
- package/build/adapters/openclaw/index.d.ts +74 -0
- package/build/adapters/openclaw/index.js +439 -0
- package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
- package/build/adapters/openclaw/mcp-tools.js +207 -0
- package/build/adapters/openclaw/plugin.d.ts +141 -0
- package/build/adapters/openclaw/plugin.js +818 -0
- package/build/adapters/openclaw/session-db.d.ts +55 -0
- package/build/adapters/openclaw/session-db.js +88 -0
- package/build/adapters/openclaw/usage.d.ts +34 -0
- package/build/adapters/openclaw/usage.js +52 -0
- package/build/adapters/openclaw/workspace-router.d.ts +29 -0
- package/build/adapters/openclaw/workspace-router.js +64 -0
- package/build/adapters/opencode/config.d.ts +8 -0
- package/build/adapters/opencode/config.js +8 -0
- package/build/adapters/opencode/hooks.d.ts +38 -0
- package/build/adapters/opencode/hooks.js +50 -0
- package/build/adapters/opencode/index.d.ts +144 -0
- package/build/adapters/opencode/index.js +638 -0
- package/build/adapters/opencode/plugin.d.ts +249 -0
- package/build/adapters/opencode/plugin.js +1536 -0
- package/build/adapters/opencode/v2.d.ts +117 -0
- package/build/adapters/opencode/v2.js +224 -0
- package/build/adapters/opencode/zod3tov4.d.ts +1 -0
- package/build/adapters/opencode/zod3tov4.js +132 -0
- package/build/adapters/pi/extension.d.ts +81 -0
- package/build/adapters/pi/extension.js +861 -0
- package/build/adapters/pi/index.d.ts +57 -0
- package/build/adapters/pi/index.js +173 -0
- package/build/adapters/pi/mcp-bridge.d.ts +268 -0
- package/build/adapters/pi/mcp-bridge.js +894 -0
- package/build/adapters/qwen-code/hooks.d.ts +26 -0
- package/build/adapters/qwen-code/hooks.js +29 -0
- package/build/adapters/qwen-code/index.d.ts +40 -0
- package/build/adapters/qwen-code/index.js +361 -0
- package/build/adapters/qwen-code/usage.d.ts +90 -0
- package/build/adapters/qwen-code/usage.js +222 -0
- package/build/adapters/types.d.ts +360 -0
- package/build/adapters/types.js +118 -0
- package/build/adapters/vscode-copilot/config.d.ts +8 -0
- package/build/adapters/vscode-copilot/config.js +8 -0
- package/build/adapters/vscode-copilot/hooks.d.ts +71 -0
- package/build/adapters/vscode-copilot/hooks.js +98 -0
- package/build/adapters/vscode-copilot/index.d.ts +32 -0
- package/build/adapters/vscode-copilot/index.js +222 -0
- package/build/adapters/zed/index.d.ts +40 -0
- package/build/adapters/zed/index.js +163 -0
- package/build/cli.d.ts +34 -0
- package/build/cli.js +1828 -0
- package/build/db-base.d.ts +250 -0
- package/build/db-base.js +756 -0
- package/build/executor.d.ts +72 -0
- package/build/executor.js +678 -0
- package/build/exit-classify.d.ts +19 -0
- package/build/exit-classify.js +12 -0
- package/build/fetch/blocks.d.ts +145 -0
- package/build/fetch/blocks.js +244 -0
- package/build/fetch/extract.d.ts +68 -0
- package/build/fetch/extract.js +123 -0
- package/build/fetch/page-store.d.ts +66 -0
- package/build/fetch/page-store.js +174 -0
- package/build/fetch-cache.d.ts +13 -0
- package/build/fetch-cache.js +15 -0
- package/build/lifecycle.d.ts +124 -0
- package/build/lifecycle.js +278 -0
- package/build/package-identity.d.ts +26 -0
- package/build/package-identity.js +28 -0
- package/build/runPool.d.ts +36 -0
- package/build/runPool.js +51 -0
- package/build/runtime.d.ts +71 -0
- package/build/runtime.js +659 -0
- package/build/search/auto-memory.d.ts +47 -0
- package/build/search/auto-memory.js +170 -0
- package/build/search/ctx-search-schema.d.ts +90 -0
- package/build/search/ctx-search-schema.js +135 -0
- package/build/search/flood-guard.d.ts +57 -0
- package/build/search/flood-guard.js +80 -0
- package/build/search/unified.d.ts +56 -0
- package/build/search/unified.js +104 -0
- package/build/security.d.ts +206 -0
- package/build/security.js +780 -0
- package/build/server.d.ts +240 -0
- package/build/server.js +5034 -0
- package/build/session/analytics.d.ts +773 -0
- package/build/session/analytics.js +2420 -0
- package/build/session/db.d.ts +468 -0
- package/build/session/db.js +1330 -0
- package/build/session/error-classifier.d.ts +87 -0
- package/build/session/error-classifier.js +303 -0
- package/build/session/event-emit.d.ts +48 -0
- package/build/session/event-emit.js +101 -0
- package/build/session/extract.d.ts +333 -0
- package/build/session/extract.js +2688 -0
- package/build/session/model-prices.json +429 -0
- package/build/session/persist-tool-calls.d.ts +54 -0
- package/build/session/persist-tool-calls.js +105 -0
- package/build/session/pricing.d.ts +64 -0
- package/build/session/pricing.js +151 -0
- package/build/session/project-attribution.d.ts +73 -0
- package/build/session/project-attribution.js +245 -0
- package/build/session/purge.d.ts +138 -0
- package/build/session/purge.js +240 -0
- package/build/session/retrieval-marker.d.ts +39 -0
- package/build/session/retrieval-marker.js +65 -0
- package/build/session/snapshot.d.ts +46 -0
- package/build/session/snapshot.js +532 -0
- package/build/store-directory.d.ts +56 -0
- package/build/store-directory.js +254 -0
- package/build/store.d.ts +147 -0
- package/build/store.js +1714 -0
- package/build/truncate.d.ts +57 -0
- package/build/truncate.js +142 -0
- package/build/types.d.ts +117 -0
- package/build/types.js +20 -0
- package/build/util/claude-config.d.ts +32 -0
- package/build/util/claude-config.js +84 -0
- package/build/util/hook-config.d.ts +27 -0
- package/build/util/hook-config.js +76 -0
- package/build/util/jsonc.d.ts +14 -0
- package/build/util/jsonc.js +104 -0
- package/build/util/plugin-cache-integrity.d.ts +51 -0
- package/build/util/plugin-cache-integrity.js +146 -0
- package/build/util/project-dir.d.ts +149 -0
- package/build/util/project-dir.js +340 -0
- package/build/util/sibling-mcp.d.ts +79 -0
- package/build/util/sibling-mcp.js +181 -0
- package/cli.bundle.mjs +1495 -0
- package/configs/antigravity/GEMINI.md +68 -0
- package/configs/antigravity/mcp_config.json +7 -0
- package/configs/antigravity-cli/hooks/hooks.json +37 -0
- package/configs/antigravity-cli/hooks.json +37 -0
- package/configs/antigravity-cli/mcp_config.json +10 -0
- package/configs/antigravity-cli/plugin.json +14 -0
- package/configs/antigravity-cli/rules/context-mode.md +77 -0
- package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
- package/configs/claude-code/CLAUDE.md +91 -0
- package/configs/codex/AGENTS.md +99 -0
- package/configs/codex/config.toml +8 -0
- package/configs/codex/hooks.json +47 -0
- package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
- package/configs/copilot-cli/.mcp.json +12 -0
- package/configs/copilot-cli/README.md +47 -0
- package/configs/copilot-cli/hooks.json +41 -0
- package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
- package/configs/cursor/context-mode.mdc +76 -0
- package/configs/cursor/hooks.json +21 -0
- package/configs/cursor/mcp.json +7 -0
- package/configs/gemini-cli/GEMINI.md +88 -0
- package/configs/gemini-cli/mcp.json +7 -0
- package/configs/gemini-cli/settings.json +60 -0
- package/configs/jetbrains-copilot/copilot-instructions.md +80 -0
- package/configs/jetbrains-copilot/hooks.json +16 -0
- package/configs/jetbrains-copilot/mcp.json +7 -0
- package/configs/kilo/AGENTS.md +84 -0
- package/configs/kilo/kilo.json +6 -0
- package/configs/kimi/hooks.json +54 -0
- package/configs/kiro/KIRO.md +84 -0
- package/configs/kiro/agent.json +18 -0
- package/configs/kiro/mcp.json +7 -0
- package/configs/omp/SYSTEM.md +85 -0
- package/configs/omp/mcp.json +7 -0
- package/configs/openclaw/AGENTS.md +87 -0
- package/configs/openclaw/openclaw.json +13 -0
- package/configs/opencode/AGENTS.md +84 -0
- package/configs/opencode/opencode.json +6 -0
- package/configs/pi/AGENTS.md +3 -0
- package/configs/qwen-code/QWEN.md +91 -0
- package/configs/vscode-copilot/copilot-instructions.md +80 -0
- package/configs/vscode-copilot/hooks.json +16 -0
- package/configs/vscode-copilot/mcp.json +7 -0
- package/configs/zed/AGENTS.md +68 -0
- package/hooks/antigravity-cli/payload.mjs +98 -0
- package/hooks/antigravity-cli/posttooluse.mjs +138 -0
- package/hooks/antigravity-cli/pretooluse.mjs +78 -0
- package/hooks/antigravity-cli/stop.mjs +58 -0
- package/hooks/auto-injection.mjs +102 -0
- package/hooks/cache-heal-utils.mjs +379 -0
- package/hooks/codex/platform.mjs +1 -0
- package/hooks/codex/posttooluse.mjs +66 -0
- package/hooks/codex/precompact.mjs +70 -0
- package/hooks/codex/pretooluse.mjs +42 -0
- package/hooks/codex/sessionstart.mjs +121 -0
- package/hooks/codex/stop.mjs +138 -0
- package/hooks/codex/userpromptsubmit.mjs +75 -0
- package/hooks/copilot-cli/posttooluse.mjs +79 -0
- package/hooks/copilot-cli/precompact.mjs +66 -0
- package/hooks/copilot-cli/pretooluse.mjs +41 -0
- package/hooks/copilot-cli/sessionstart.mjs +121 -0
- package/hooks/copilot-cli/stop.mjs +59 -0
- package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
- package/hooks/core/codex-caps.mjs +112 -0
- package/hooks/core/formatters.mjs +356 -0
- package/hooks/core/mcp-ready.mjs +109 -0
- package/hooks/core/platform-detect.mjs +49 -0
- package/hooks/core/routing.mjs +1050 -0
- package/hooks/core/stdin.mjs +90 -0
- package/hooks/core/tool-naming.mjs +57 -0
- package/hooks/cursor/afteragentresponse.mjs +74 -0
- package/hooks/cursor/hooks.json +31 -0
- package/hooks/cursor/posttooluse.mjs +72 -0
- package/hooks/cursor/pretooluse.mjs +26 -0
- package/hooks/cursor/sessionstart.mjs +97 -0
- package/hooks/cursor/stop.mjs +48 -0
- package/hooks/ensure-deps.mjs +254 -0
- package/hooks/formatters/claude-code.mjs +113 -0
- package/hooks/formatters/cursor.mjs +37 -0
- package/hooks/formatters/gemini-cli.mjs +55 -0
- package/hooks/formatters/vscode-copilot.mjs +55 -0
- package/hooks/gemini-cli/aftermodel.mjs +70 -0
- package/hooks/gemini-cli/aftertool.mjs +61 -0
- package/hooks/gemini-cli/beforeagent.mjs +99 -0
- package/hooks/gemini-cli/beforetool.mjs +27 -0
- package/hooks/gemini-cli/precompress.mjs +55 -0
- package/hooks/gemini-cli/sessionstart.mjs +137 -0
- package/hooks/heal-partial-install.mjs +712 -0
- package/hooks/hooks.json +143 -0
- package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
- package/hooks/jetbrains-copilot/precompact.mjs +55 -0
- package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
- package/hooks/jetbrains-copilot/sessionstart.mjs +124 -0
- package/hooks/kimi/platform.mjs +1 -0
- package/hooks/kimi/posttooluse.mjs +72 -0
- package/hooks/kimi/precompact.mjs +80 -0
- package/hooks/kimi/pretooluse.mjs +42 -0
- package/hooks/kimi/sessionend.mjs +61 -0
- package/hooks/kimi/sessionstart.mjs +113 -0
- package/hooks/kimi/stop.mjs +132 -0
- package/hooks/kimi/userpromptsubmit.mjs +90 -0
- package/hooks/kiro/agentspawn.mjs +97 -0
- package/hooks/kiro/posttooluse.mjs +51 -0
- package/hooks/kiro/pretooluse.mjs +64 -0
- package/hooks/kiro/userpromptsubmit.mjs +88 -0
- package/hooks/normalize-hooks.mjs +323 -0
- package/hooks/platform-bridge.mjs +340 -0
- package/hooks/posttooluse.mjs +225 -0
- package/hooks/precompact.mjs +100 -0
- package/hooks/pretooluse.mjs +227 -0
- package/hooks/qwen-code/platform.mjs +1 -0
- package/hooks/qwen-code/stop.mjs +168 -0
- package/hooks/routing-block.mjs +105 -0
- package/hooks/run-hook.mjs +95 -0
- package/hooks/security.bundle.mjs +2 -0
- package/hooks/session-attribution.bundle.mjs +1 -0
- package/hooks/session-db.bundle.mjs +145 -0
- package/hooks/session-directive.mjs +520 -0
- package/hooks/session-extract.bundle.mjs +3 -0
- package/hooks/session-helpers.mjs +428 -0
- package/hooks/session-loaders.mjs +448 -0
- package/hooks/session-snapshot.bundle.mjs +32 -0
- package/hooks/sessionstart.mjs +464 -0
- package/hooks/stop.mjs +82 -0
- package/hooks/suppress-stderr.mjs +25 -0
- package/hooks/userpromptsubmit.mjs +108 -0
- package/hooks/vscode-copilot/posttooluse.mjs +61 -0
- package/hooks/vscode-copilot/precompact.mjs +55 -0
- package/hooks/vscode-copilot/pretooluse.mjs +27 -0
- package/hooks/vscode-copilot/sessionstart.mjs +129 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +132 -0
- package/scripts/heal-better-sqlite3.mjs +430 -0
- package/scripts/heal-installed-plugins.mjs +635 -0
- package/scripts/plugin-cache-integrity.mjs +248 -0
- package/scripts/postinstall.mjs +397 -0
- package/server.bundle.mjs +1421 -0
- package/server.js +5 -0
- package/skills/.ignore +7 -0
- package/skills/context-mode/SKILL.md +300 -0
- package/skills/context-mode/references/anti-patterns.md +283 -0
- package/skills/context-mode/references/patterns-javascript.md +298 -0
- package/skills/context-mode/references/patterns-python.md +304 -0
- package/skills/context-mode/references/patterns-shell.md +277 -0
- package/skills/ctx-doctor/SKILL.md +22 -0
- package/skills/ctx-index/SKILL.md +46 -0
- package/skills/ctx-insight/SKILL.md +24 -0
- package/skills/ctx-purge/SKILL.md +49 -0
- package/skills/ctx-search/SKILL.md +35 -0
- package/skills/ctx-stats/SKILL.md +26 -0
- package/skills/ctx-upgrade/SKILL.md +31 -0
- package/start.mjs +616 -0
|
@@ -0,0 +1,1536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode / KiloCode TypeScript plugin entry point for context-mode.
|
|
3
|
+
*
|
|
4
|
+
* Dual-flavor compatibility (opencode v1 + v2):
|
|
5
|
+
* - v1 hosts load the plugin via `server(input)` (PluginModule default
|
|
6
|
+
* export with shape `{ id, server }`).
|
|
7
|
+
* - v2 hosts load the plugin via `setup(ctx)` on the default-export object
|
|
8
|
+
* `{ id, server?, setup }` (v2 PluginModule shape).
|
|
9
|
+
* Both entries live on the SAME default export object; each host picks the
|
|
10
|
+
* entrypoint it supports. No `tui` marker is set (it would invalidate
|
|
11
|
+
* server loading per the v2 migration guide).
|
|
12
|
+
*
|
|
13
|
+
* The v2 PluginContext surface is VERIFIED (opencode2 beta-19135 live probe
|
|
14
|
+
* + docs at opencode.ai/v2/docs/build/plugins): ctx.tool.transform(editor)
|
|
15
|
+
* registers native ctx_* tools (ToolEditor), ctx.tool.hook
|
|
16
|
+
* ("execute.before"/"execute.after") bridges routing enforcement + capture,
|
|
17
|
+
* ctx.session.hook("context"|"prompt") covers system-context injection
|
|
18
|
+
* (continuations AND compaction) and user-prompt capture, and
|
|
19
|
+
* ctx.event.subscribe() streams bus events. The v2 `setup` path probes each
|
|
20
|
+
* surface defensively (typeof checks; UNCONFIRMED payload fields are read
|
|
21
|
+
* defensively) and degrades HONESTLY — one-time log, never fake success.
|
|
22
|
+
* Mandatory for activation: native tool registration AND execute hooks;
|
|
23
|
+
* anything mandatory missing → no claim (MCP fallback stays intact); the
|
|
24
|
+
* session/event surfaces are optional and degrade with one-time logs.
|
|
25
|
+
*
|
|
26
|
+
* Hybrid-host guard: a process-global activation registry (keyed by plugin
|
|
27
|
+
* id + normalized project directory) ensures only ONE flavor activates per
|
|
28
|
+
* project per process. The second entrypoint (v1 server() or v2 setup())
|
|
29
|
+
* returns a noop/empty registration and logs once ("context-mode already
|
|
30
|
+
* active via <flavor>"). v1 claims if no claimant exists; v1 behavior is
|
|
31
|
+
* otherwise unchanged for existing users.
|
|
32
|
+
*
|
|
33
|
+
* Five hooks (v1.0.107 — Mickey OC-1..OC-4 follow-up):
|
|
34
|
+
* - tool.execute.before — Routing enforcement (deny/modify/passthrough)
|
|
35
|
+
* - tool.execute.after — Session event capture + first-fire AGENTS.md scan (OC-4)
|
|
36
|
+
* - experimental.session.compacting — Compaction snapshot + budget-capped auto-injection (OC-3)
|
|
37
|
+
* - experimental.chat.system.transform — ROUTING_BLOCK + resume snapshot injection (OC-1)
|
|
38
|
+
* - chat.message — User-prompt capture w/ CCv2 inline filter (OC-2) + AGENTS.md scan (OC-4)
|
|
39
|
+
*
|
|
40
|
+
* KiloCode loads this via: import("@mxalbert/context-mode") → expects default export
|
|
41
|
+
* with shape { server: (input) => Promise<Hooks> } (PluginModule).
|
|
42
|
+
*
|
|
43
|
+
* OpenCode loads this via: import("@mxalbert/context-mode/plugin") → also supports
|
|
44
|
+
* the named export ContextModePlugin for backward compat.
|
|
45
|
+
*
|
|
46
|
+
* Constraints:
|
|
47
|
+
* - No SessionStart hook (OpenCode doesn't support it — #14808, #5409)
|
|
48
|
+
* - context injection now via chat.system.transform surrogate (OC-1)
|
|
49
|
+
* - No routing file auto-write (avoid dirtying project trees)
|
|
50
|
+
* - Session cleanup happens at plugin init (no SessionStart)
|
|
51
|
+
*/
|
|
52
|
+
import { dirname, resolve, join } from "node:path";
|
|
53
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
54
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
55
|
+
import { homedir } from "node:os";
|
|
56
|
+
import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
|
|
57
|
+
import { extractEvents, extractUserEvents, parseOpencodeUsage, buildAgentUsageEvent } from "../../session/extract.js";
|
|
58
|
+
import { buildResumeSnapshot } from "../../session/snapshot.js";
|
|
59
|
+
import { OpenCodeAdapter, getPluginGlobalState, normalizeProjectKey, } from "./index.js";
|
|
60
|
+
import { PLATFORM_ENV_VARS } from "../detect.js";
|
|
61
|
+
import { zod3ShapeToV4 } from "./zod3tov4.js";
|
|
62
|
+
import { extractV2ToolErrorText, extractV2ToolResultText, v2SessionIdOf, v2SystemPartText, v2ToolNameOf, zodSchemaToJsonSchema, } from "./v2.js";
|
|
63
|
+
// v2 setup-context / ToolEditor / ToolInfo types live in ./v2.ts (imported
|
|
64
|
+
// above) — VERIFIED surface + defensive payload readers live there too.
|
|
65
|
+
// Synthetic message tags emitted by harnesses (CCv2 inline filter). When the
|
|
66
|
+
// user "message" is actually a system-generated nudge (e.g. tool-result, system
|
|
67
|
+
// reminder), capturing it as user_prompt would flood the DB with noise.
|
|
68
|
+
const SYNTHETIC_MESSAGE_PREFIXES = [
|
|
69
|
+
"<task-notification>",
|
|
70
|
+
"<system-reminder>",
|
|
71
|
+
"<context_guidance>",
|
|
72
|
+
"<tool-result>",
|
|
73
|
+
];
|
|
74
|
+
function isSyntheticMessage(text) {
|
|
75
|
+
const trimmed = text.trim();
|
|
76
|
+
return SYNTHETIC_MESSAGE_PREFIXES.some((p) => trimmed.startsWith(p));
|
|
77
|
+
}
|
|
78
|
+
// ── Helpers ───────────────────────────────────────────────
|
|
79
|
+
// Quorum markers — must NOT be substrings of each other (#487).
|
|
80
|
+
// Each token uniquely identifies the routing block / context-mode rules
|
|
81
|
+
// without overlapping any other marker. The XML tag is the primary signal;
|
|
82
|
+
// the two distinctive bare tool names are the secondary signals. Together
|
|
83
|
+
// any 2 of 3 confirm the system prompt already carries routing instructions.
|
|
84
|
+
const ROUTING_MARKERS = [
|
|
85
|
+
"<context_window_protection>",
|
|
86
|
+
"ctx_search",
|
|
87
|
+
"ctx_index",
|
|
88
|
+
];
|
|
89
|
+
function systemHasRoutingInstructions(system) {
|
|
90
|
+
const text = system.join("\n");
|
|
91
|
+
// Word-boundary check guards against unrelated identifiers that happen to
|
|
92
|
+
// share a prefix/suffix (e.g. a hypothetical `ctx_search_v2`).
|
|
93
|
+
const wordBoundary = (m) => {
|
|
94
|
+
if (m.startsWith("<"))
|
|
95
|
+
return text.includes(m);
|
|
96
|
+
const re = new RegExp(`(?:^|\\W)${m.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}(?:\\W|$)`);
|
|
97
|
+
return re.test(text);
|
|
98
|
+
};
|
|
99
|
+
return ROUTING_MARKERS.filter(wordBoundary).length >= 2;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Detect whether the plugin is running under KiloCode or OpenCode.
|
|
103
|
+
*
|
|
104
|
+
* Reuses the canonical PLATFORM_ENV_VARS list (src/adapters/detect.ts) instead
|
|
105
|
+
* of hardcoding env var names — single source of truth, future-proof if Kilo
|
|
106
|
+
* or OpenCode add/rename env vars upstream.
|
|
107
|
+
*
|
|
108
|
+
* Order matters: KiloCode is an OpenCode fork and sets `OPENCODE=1` in
|
|
109
|
+
* addition to `KILO_PID`. PLATFORM_ENV_VARS lists `kilo` BEFORE `opencode`
|
|
110
|
+
* so KILO_PID wins the iteration.
|
|
111
|
+
*
|
|
112
|
+
* Pre-fix version was `return process.env.KILO_PID ? "kilo" : "opencode";` —
|
|
113
|
+
* surfaced by github.com/mksglu/context-mode/pull/376 (mikij). Full symmetric
|
|
114
|
+
* fix: also actively check opencode env vars instead of blind fallback.
|
|
115
|
+
*/
|
|
116
|
+
function getPlatform() {
|
|
117
|
+
for (const [platform, vars] of PLATFORM_ENV_VARS) {
|
|
118
|
+
if (platform !== "kilo" && platform !== "opencode")
|
|
119
|
+
continue;
|
|
120
|
+
if (vars.some((v) => process.env[v.name])) {
|
|
121
|
+
return platform;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Plugin host should always set one of the env vars. Fallback to opencode
|
|
125
|
+
// (the wider ecosystem) when neither is set, for predictable behavior.
|
|
126
|
+
return "opencode";
|
|
127
|
+
}
|
|
128
|
+
// ── Hybrid-host guard state (shared machinery lives in hooks.ts) ────
|
|
129
|
+
/** Test-only: clear process-global activation + log-dedupe state. */
|
|
130
|
+
export function __resetPluginStateForTests() {
|
|
131
|
+
const state = getPluginGlobalState();
|
|
132
|
+
state.activations.clear();
|
|
133
|
+
state.loggedOnce.clear();
|
|
134
|
+
state.errorDedupe.clear();
|
|
135
|
+
}
|
|
136
|
+
/** Test-only: expose the process-global state for assertions. */
|
|
137
|
+
export function __getPluginGlobalState() {
|
|
138
|
+
return getPluginGlobalState();
|
|
139
|
+
}
|
|
140
|
+
// ── Logging (detailed error reporting for hook catch-alls) ────
|
|
141
|
+
/**
|
|
142
|
+
* Write to stderr — NEVER stdout (stdout is a protocol/transport channel for
|
|
143
|
+
* MCP hosts and must never receive diagnostics).
|
|
144
|
+
*/
|
|
145
|
+
function stderrWrite(message) {
|
|
146
|
+
try {
|
|
147
|
+
process.stderr.write(`[context-mode] ${message}\n`);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// stderr unavailable — nothing further we can do
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ── File sink (opencode data-dir diagnostics log) ─────────
|
|
154
|
+
// Plugin diagnostics must NOT leak onto the v1 TUI (user decision): the
|
|
155
|
+
// stderr fallback exists solely as a last resort when the file sink itself
|
|
156
|
+
// is unavailable. Both opencode v1 and v2 use the data dir name `opencode`
|
|
157
|
+
// under the XDG data root (v1 already keeps `log/` there), so the plugin
|
|
158
|
+
// appends its diagnostics next to the host's own dated log files.
|
|
159
|
+
let sinkFd = null;
|
|
160
|
+
let sinkPath = null;
|
|
161
|
+
let sinkFailed = false;
|
|
162
|
+
/** Resolve the sink path — verified layout: <data-root>/opencode/log/context-mode.log. */
|
|
163
|
+
function resolveSinkPath() {
|
|
164
|
+
const dataRoot = process.env.XDG_DATA_HOME && process.env.XDG_DATA_HOME.length > 0
|
|
165
|
+
? process.env.XDG_DATA_HOME
|
|
166
|
+
: join(homedir(), ".local", "share");
|
|
167
|
+
return join(dataRoot, "opencode", "log", "context-mode.log");
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Lazily open the append fd (O_APPEND|O_CREAT|O_WRONLY). Returns the sink
|
|
171
|
+
* path on success or null when the sink is unavailable (mkdir/open failed,
|
|
172
|
+
* or a previous write failure marked the sink dead). Never throws.
|
|
173
|
+
*/
|
|
174
|
+
function sinkLogFilePath() {
|
|
175
|
+
if (sinkFailed)
|
|
176
|
+
return null;
|
|
177
|
+
if (sinkFd !== null && sinkPath !== null)
|
|
178
|
+
return sinkPath;
|
|
179
|
+
const path = resolveSinkPath();
|
|
180
|
+
try {
|
|
181
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
sinkFailed = true;
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
sinkFd = openSync(path, "a");
|
|
189
|
+
sinkPath = path;
|
|
190
|
+
return path;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
sinkFailed = true;
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* One newline-terminated line: `[<ISO-8601>] [<LEVEL>] <message>`.
|
|
199
|
+
* Returns false on ANY write failure (the fd is then closed and the sink
|
|
200
|
+
* marked dead so the caller can degrade to stderr). Never throws.
|
|
201
|
+
*/
|
|
202
|
+
function writeSinkLine(level, message) {
|
|
203
|
+
const fd = sinkFd;
|
|
204
|
+
if (fd === null)
|
|
205
|
+
return false;
|
|
206
|
+
try {
|
|
207
|
+
writeSync(fd, `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}\n`);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
try {
|
|
212
|
+
closeSync(fd);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Best-effort close — the fd may already be unusable.
|
|
216
|
+
}
|
|
217
|
+
sinkFd = null;
|
|
218
|
+
sinkPath = null;
|
|
219
|
+
sinkFailed = true;
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Test-only: close the cached fd and clear the sink cache so each test gets a fresh sink. */
|
|
224
|
+
export function __resetPluginLogSinkForTests() {
|
|
225
|
+
if (sinkFd !== null) {
|
|
226
|
+
try {
|
|
227
|
+
closeSync(sinkFd);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
/* already closed */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
sinkFd = null;
|
|
234
|
+
sinkPath = null;
|
|
235
|
+
sinkFailed = false;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* SINGLE best-effort host-logger for the whole plugin (unified stack).
|
|
239
|
+
*
|
|
240
|
+
* Policy (user decision: plugin diagnostics must NOT leak onto the v1 TUI):
|
|
241
|
+
* 1. ALWAYS write to the file sink inside opencode's data dir
|
|
242
|
+
* (<XDG_DATA_HOME ?? ~/.local/share>/opencode/log/context-mode.log).
|
|
243
|
+
* 2. Additionally call ctx.client.app.log when available (v2 host
|
|
244
|
+
* integration — never touches the TUI in normal runs).
|
|
245
|
+
* 3. stderr ONLY as the last resort — when the file sink is unavailable
|
|
246
|
+
* or its write failed.
|
|
247
|
+
* Never throws and never rejects: log failures must not break hooks or
|
|
248
|
+
* turns. All emitters (debug safeLog, deduped hook-error logs, one-time
|
|
249
|
+
* setup/degradation logs, setup failure logs) route through this function;
|
|
250
|
+
* the dedupe/rate-limit and OPENCODE_DEBUG stack policy live in the callers
|
|
251
|
+
* that own those semantics (logHookErrorImpl / formatPluginError).
|
|
252
|
+
*/
|
|
253
|
+
function emitHostLog(ctx, level, message, extra) {
|
|
254
|
+
const maybeClient = ctx?.client;
|
|
255
|
+
const logFn = maybeClient && typeof maybeClient === "object"
|
|
256
|
+
? maybeClient?.app?.log
|
|
257
|
+
: undefined;
|
|
258
|
+
const hasExtra = extra !== undefined && extra !== null;
|
|
259
|
+
// 1. File sink — ALWAYS.
|
|
260
|
+
let sinkOk = false;
|
|
261
|
+
const sinkPath = sinkLogFilePath();
|
|
262
|
+
if (sinkPath !== null)
|
|
263
|
+
sinkOk = writeSinkLine(level, message);
|
|
264
|
+
// 2. Host client integration — preserved.
|
|
265
|
+
if (typeof logFn === "function") {
|
|
266
|
+
try {
|
|
267
|
+
Promise.resolve(logFn.call(maybeClient, {
|
|
268
|
+
body: {
|
|
269
|
+
service: "context-mode-logger",
|
|
270
|
+
level,
|
|
271
|
+
message,
|
|
272
|
+
extra: hasExtra ? extra : undefined,
|
|
273
|
+
},
|
|
274
|
+
})).catch(() => {
|
|
275
|
+
if (!sinkOk)
|
|
276
|
+
stderrWrite(message);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
if (!sinkOk)
|
|
281
|
+
stderrWrite(message);
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// 3. stderr last-resort ONLY when the file sink failed.
|
|
286
|
+
if (!sinkOk)
|
|
287
|
+
stderrWrite(message);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Format a plugin error line: plugin tag, hook name, sessionId, error
|
|
291
|
+
* name/code/message. Stack is FULL when OPENCODE_DEBUG, otherwise the first
|
|
292
|
+
* ~2 stack lines are included.
|
|
293
|
+
*/
|
|
294
|
+
function formatPluginError(hookName, err, sessionId) {
|
|
295
|
+
const e = (err ?? {});
|
|
296
|
+
const message = typeof e.message === "string" && e.message ? e.message : String(err);
|
|
297
|
+
const head = `[context-mode] hook ${hookName ?? "plugin"}` +
|
|
298
|
+
(sessionId ? ` (session ${sessionId})` : "") +
|
|
299
|
+
` failed: ` +
|
|
300
|
+
[e.name, e.code ? `code=${e.code}` : null, message].filter(Boolean).join(" ");
|
|
301
|
+
const stack = typeof e.stack === "string" && e.stack ? e.stack : "";
|
|
302
|
+
if (process.env.OPENCODE_DEBUG) {
|
|
303
|
+
return stack ? `${head}\n${stack}` : head;
|
|
304
|
+
}
|
|
305
|
+
const stackLines = stack
|
|
306
|
+
? stack.split("\n").slice(1, 3).filter((l) => l.trim().length > 0)
|
|
307
|
+
: [];
|
|
308
|
+
return stackLines.length > 0 ? `${head}\n${stackLines.join("\n")}` : head;
|
|
309
|
+
}
|
|
310
|
+
const ERROR_LOG_DEDUPE_WINDOW_MS = 30_000;
|
|
311
|
+
const ERROR_LOG_DEDUPE_MAX_KEYS = 256;
|
|
312
|
+
/**
|
|
313
|
+
* Detailed, rate-limited error logging for the hook catch-alls. Identical
|
|
314
|
+
* errors (same hook + code + message) are logged at most once per 30s so a
|
|
315
|
+
* wedged SQLite handle does not spam the log on every tool call.
|
|
316
|
+
*/
|
|
317
|
+
function logHookErrorImpl(state, ctx, hookName, err, sessionId) {
|
|
318
|
+
const e = (err ?? {});
|
|
319
|
+
const message = typeof e.message === "string" && e.message ? e.message : String(err);
|
|
320
|
+
const key = `${hookName}|${e.code ?? ""}|${message}`;
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
const last = state.errorDedupe.get(key);
|
|
323
|
+
if (last !== undefined && now - last < ERROR_LOG_DEDUPE_WINDOW_MS)
|
|
324
|
+
return;
|
|
325
|
+
if (state.errorDedupe.size >= ERROR_LOG_DEDUPE_MAX_KEYS) {
|
|
326
|
+
// Bounded memory: drop the oldest entry before inserting.
|
|
327
|
+
const oldest = state.errorDedupe.keys().next().value;
|
|
328
|
+
if (oldest !== undefined)
|
|
329
|
+
state.errorDedupe.delete(oldest);
|
|
330
|
+
}
|
|
331
|
+
state.errorDedupe.set(key, now);
|
|
332
|
+
emitHostLog(ctx, "error", formatPluginError(hookName, err, sessionId), { sessionId });
|
|
333
|
+
}
|
|
334
|
+
/** One-time log — each key is emitted at most once per process. */
|
|
335
|
+
function logOnceImpl(state, ctx, key, message, level = "info") {
|
|
336
|
+
if (state.loggedOnce.has(key))
|
|
337
|
+
return;
|
|
338
|
+
state.loggedOnce.add(key);
|
|
339
|
+
emitHostLog(ctx, level, message);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Initialize the shared plugin runtime: dynamic imports of the .mjs islands,
|
|
343
|
+
* routing security init, per-project SessionDB (eager — matches v1 behavior),
|
|
344
|
+
* startup session cleanup, and the AGENTS.md capture machinery (OC-4).
|
|
345
|
+
*/
|
|
346
|
+
async function createPluginRuntime(ctx, projectDirOverride, adapterOptions) {
|
|
347
|
+
// Resolve build dir from compiled JS location
|
|
348
|
+
const platform = getPlatform();
|
|
349
|
+
const adapter = new OpenCodeAdapter(platform, adapterOptions);
|
|
350
|
+
const buildDir = dirname(fileURLToPath(import.meta.url));
|
|
351
|
+
// initSecurity() looks for `<dir>/security.js`, which lives at the
|
|
352
|
+
// top of build/ — two levels up from this adapter directory.
|
|
353
|
+
const buildRoot = resolve(buildDir, "..", "..");
|
|
354
|
+
// Load routing module (ESM .mjs, lives outside build/ in hooks/)
|
|
355
|
+
const routingPath = resolve(buildDir, "..", "..", "..", "hooks", "core", "routing.mjs");
|
|
356
|
+
const routing = (await import(pathToFileURL(routingPath).href));
|
|
357
|
+
await routing.initSecurity(buildRoot);
|
|
358
|
+
// OC-1 / OC-3: Load hook helpers once at plugin init. Dynamic import keeps
|
|
359
|
+
// the .mjs ESM islands isolated from the .ts compile graph.
|
|
360
|
+
const routingBlockPath = resolve(buildDir, "..", "..", "..", "hooks", "routing-block.mjs");
|
|
361
|
+
const routingBlockMod = await import(pathToFileURL(routingBlockPath).href);
|
|
362
|
+
const toolNamingPath = resolve(buildDir, "..", "..", "..", "hooks", "core", "tool-naming.mjs");
|
|
363
|
+
const toolNamingMod = await import(pathToFileURL(toolNamingPath).href);
|
|
364
|
+
const autoInjectionPath = resolve(buildDir, "..", "..", "..", "hooks", "auto-injection.mjs");
|
|
365
|
+
const autoInjectionMod = (await import(pathToFileURL(autoInjectionPath).href));
|
|
366
|
+
// Pre-build the routing block once per process — it is platform-specific
|
|
367
|
+
// (tool naming differs between opencode and kilo) but does NOT depend on
|
|
368
|
+
// sessionID, so we cache it. createToolNamer accepts both "opencode" and
|
|
369
|
+
// "kilo" per hooks/core/tool-naming.mjs:25-26.
|
|
370
|
+
const toolNamer = toolNamingMod.createToolNamer(platform);
|
|
371
|
+
const routingBlock = routingBlockMod.createRoutingBlock(toolNamer);
|
|
372
|
+
// Initialize per-process state. We do NOT fabricate a sessionId here —
|
|
373
|
+
// OpenCode/Kilo provide the real `input.sessionID` on every hook, and a
|
|
374
|
+
// process-global UUID would (a) never match prior-session resume rows and
|
|
375
|
+
// (b) collide across multi-session reuse (Mickey / PR #376 root cause).
|
|
376
|
+
const projectDir = projectDirOverride ?? ctx?.directory ?? process.cwd();
|
|
377
|
+
// C2 narrowing: resolve DB path through the canonical helper directly.
|
|
378
|
+
// BaseAdapter no longer exposes getSessionDBPath; the adapter only owns
|
|
379
|
+
// the sessions DIR (per-platform), the helper owns the per-project FILE
|
|
380
|
+
// (case-fold + worktree-suffix + one-shot legacy migration).
|
|
381
|
+
const db = new SessionDB({
|
|
382
|
+
dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }),
|
|
383
|
+
});
|
|
384
|
+
// Clean up old sessions on startup (no SessionStart hook to do this).
|
|
385
|
+
db.cleanupOldSessions(7);
|
|
386
|
+
// OC-4 (#487 follow-up): per-session capture gate. PR #487 trusted the host
|
|
387
|
+
// to deliver AGENTS.md events, but OpenCode only fires `rule_content` events
|
|
388
|
+
// when the user explicitly reads the file. snapshot.ts:172 + analytics.ts:152
|
|
389
|
+
// CONSUME `rule_content` to render rules into the resume snapshot — without
|
|
390
|
+
// this capture path, AGENTS.md is silently absent from continuity output.
|
|
391
|
+
// Keyed by sessionId (NOT projectDir) so multi-session reuse within a long-
|
|
392
|
+
// lived plugin process still gets per-session capture exactly once.
|
|
393
|
+
const agentsMdCaptured = new Set();
|
|
394
|
+
/**
|
|
395
|
+
* OC-4: Read AGENTS.md (with CLAUDE.md / CONTEXT.md fallbacks) from the
|
|
396
|
+
* project directory and persist as `rule` + `rule_content` events. Mirrors
|
|
397
|
+
* the CC SessionStart pattern at hooks/sessionstart.mjs:121-132 and the
|
|
398
|
+
* OpenCode instruction.ts FILES order. Idempotent via `agentsMdCaptured`
|
|
399
|
+
* Set keyed by sessionId. Fail-soft: missing/unreadable files do not throw.
|
|
400
|
+
*/
|
|
401
|
+
function captureAgentsMd(sessionId) {
|
|
402
|
+
if (agentsMdCaptured.has(sessionId))
|
|
403
|
+
return;
|
|
404
|
+
agentsMdCaptured.add(sessionId);
|
|
405
|
+
const candidates = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"];
|
|
406
|
+
for (const name of candidates) {
|
|
407
|
+
try {
|
|
408
|
+
const p = join(projectDir, name);
|
|
409
|
+
if (!existsSync(p))
|
|
410
|
+
continue;
|
|
411
|
+
const content = readFileSync(p, "utf-8");
|
|
412
|
+
if (!content.trim())
|
|
413
|
+
continue;
|
|
414
|
+
db.insertEvent(sessionId, {
|
|
415
|
+
type: "rule",
|
|
416
|
+
category: "rule",
|
|
417
|
+
data: p,
|
|
418
|
+
priority: 1,
|
|
419
|
+
}, "PluginInit");
|
|
420
|
+
db.insertEvent(sessionId, {
|
|
421
|
+
type: "rule_content",
|
|
422
|
+
category: "rule",
|
|
423
|
+
data: content,
|
|
424
|
+
priority: 1,
|
|
425
|
+
}, "PluginInit");
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// file missing or unreadable — skip silently
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Debug logger — thin wrapper over the UNIFIED host-logger (emitHostLog).
|
|
434
|
+
* Same body shape as before (service/level/message/extra), but never
|
|
435
|
+
* rejects: emitHostLog already handles transport failures by falling back
|
|
436
|
+
* to stderr, so a broken ctx.client.app.log cannot break the turn (#448).
|
|
437
|
+
*/
|
|
438
|
+
function logger(message = "context-mode debug log", extra) {
|
|
439
|
+
emitHostLog(ctx, "info", message, extra);
|
|
440
|
+
return Promise.resolve();
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Drop-in wrapper for `logger` that NEVER rejects (#448).
|
|
444
|
+
*
|
|
445
|
+
* The OPENCODE_DEBUG branch awaits `logger(...)` from inside the chat-turn
|
|
446
|
+
* hot path (chat.system.transform). If `ctx.client.app.log` rejects —
|
|
447
|
+
* transport error, closed stream, oversized payload — the promise rejection
|
|
448
|
+
* propagates back to OpenCode core and can break the turn. Debug logging
|
|
449
|
+
* is best-effort; swallow errors silently and let the turn proceed.
|
|
450
|
+
*/
|
|
451
|
+
async function safeLog(message, extra) {
|
|
452
|
+
try {
|
|
453
|
+
await logger(message, extra);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Never break the turn on debug-log failure.
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async function buildNativeTools() {
|
|
460
|
+
// Import the existing MCP server registry without starting its stdio
|
|
461
|
+
// transport. This is the plugin-only bridge for #574: OpenCode/Kilo
|
|
462
|
+
// call ctx_* tools in-process through Hooks.tool instead of spawning
|
|
463
|
+
// a separate MCP child per session.
|
|
464
|
+
const prevEmbedded = process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
|
|
465
|
+
process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS = "1";
|
|
466
|
+
let mod;
|
|
467
|
+
try {
|
|
468
|
+
mod = await import("../../server.js");
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
if (prevEmbedded === undefined)
|
|
472
|
+
delete process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
|
|
473
|
+
else
|
|
474
|
+
process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS = prevEmbedded;
|
|
475
|
+
}
|
|
476
|
+
const tools = {};
|
|
477
|
+
for (const registered of mod.REGISTERED_CTX_TOOLS) {
|
|
478
|
+
const config = registered.config;
|
|
479
|
+
// Zod schema object that the MCP framework normally calls
|
|
480
|
+
// safeParseAsync() on before invoking the handler. The native
|
|
481
|
+
// OpenCode plugin path bypasses MCP's transport layer entirely
|
|
482
|
+
// (refs/platforms/opencode/packages/opencode/src/tool/registry.ts:127),
|
|
483
|
+
// so we must parse args here too — otherwise z.preprocess() coercions
|
|
484
|
+
// (coerceCommandsArray / coerceJsonArray in server.ts) and defaults
|
|
485
|
+
// never fire. Fixes #621.
|
|
486
|
+
const inputSchema = config.inputSchema;
|
|
487
|
+
const shape = typeof inputSchema?.shape === "object" && inputSchema.shape !== null
|
|
488
|
+
? inputSchema.shape
|
|
489
|
+
: typeof inputSchema?._def?.shape === "function"
|
|
490
|
+
? inputSchema._def.shape()
|
|
491
|
+
: {};
|
|
492
|
+
// Both KiloCode and recent OpenCode bundle Zod v4 in-host; v3 schemas
|
|
493
|
+
// crash with `n._zod.def` undefined. Gate widened from kilo-only (#632)
|
|
494
|
+
// because every consumer of this file is an OpenCode-family host.
|
|
495
|
+
const argsForHost = zod3ShapeToV4(shape);
|
|
496
|
+
tools[registered.name] = {
|
|
497
|
+
description: String(config.description ?? ""),
|
|
498
|
+
args: argsForHost,
|
|
499
|
+
// v2-only consumer: JSON-Schema view of the SAME Zod schema (the
|
|
500
|
+
// MCP layer hands the Zod schema to the SDK, which converts
|
|
501
|
+
// internally — this helper is the shared conversion point here).
|
|
502
|
+
inputJsonSchema: zodSchemaToJsonSchema(inputSchema),
|
|
503
|
+
async execute(args, toolCtx) {
|
|
504
|
+
toolCtx.metadata?.({ title: String(config.title ?? registered.name) });
|
|
505
|
+
const project = toolCtx.directory || projectDir;
|
|
506
|
+
// Run the registered Zod schema BEFORE the handler — same contract
|
|
507
|
+
// as the MCP SDK (server/mcp.js safeParseAsync at line 174). This
|
|
508
|
+
// applies z.preprocess() coercions, populates .default() values,
|
|
509
|
+
// and produces the validation error the handler expects (#621).
|
|
510
|
+
let parsedArgs = args ?? {};
|
|
511
|
+
if (typeof inputSchema?.parse === "function") {
|
|
512
|
+
try {
|
|
513
|
+
parsedArgs = inputSchema.parse(args ?? {});
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
// Surface validation failures with a clear, actionable message
|
|
517
|
+
// (mirrors MCP SDK error format) instead of a downstream
|
|
518
|
+
// "x.map is not a function" crash.
|
|
519
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
520
|
+
throw new Error(`Invalid arguments for ${registered.name}: ${message}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const result = await mod.withProjectDirOverride({ projectDir: project, sessionId: toolCtx.sessionID }, async () => registered.handler(parsedArgs));
|
|
524
|
+
const r = result;
|
|
525
|
+
const text = Array.isArray(r?.content)
|
|
526
|
+
? r.content
|
|
527
|
+
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
|
528
|
+
.map((c) => c.text)
|
|
529
|
+
.join("\n")
|
|
530
|
+
: typeof result === "string"
|
|
531
|
+
? result
|
|
532
|
+
: JSON.stringify(result ?? "");
|
|
533
|
+
if (r?.isError)
|
|
534
|
+
throw new Error(text || `${registered.name} returned an error`);
|
|
535
|
+
return { title: String(config.title ?? registered.name), output: text };
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return tools;
|
|
540
|
+
}
|
|
541
|
+
const state = getPluginGlobalState();
|
|
542
|
+
return {
|
|
543
|
+
ctx,
|
|
544
|
+
platform,
|
|
545
|
+
adapter,
|
|
546
|
+
projectDir,
|
|
547
|
+
db,
|
|
548
|
+
routing,
|
|
549
|
+
routingBlock,
|
|
550
|
+
autoInjectionMod,
|
|
551
|
+
captureAgentsMd,
|
|
552
|
+
buildNativeTools,
|
|
553
|
+
logger,
|
|
554
|
+
safeLog,
|
|
555
|
+
closed: false,
|
|
556
|
+
logHookError: (hookName, err, sessionId) => logHookErrorImpl(state, ctx, hookName, err, sessionId),
|
|
557
|
+
logOnce: (key, message, level = "info") => logOnceImpl(state, ctx, key, message, level),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function createSharedHandlers(rt) {
|
|
561
|
+
return {
|
|
562
|
+
// ── PreToolUse: Routing enforcement ─────────────────
|
|
563
|
+
"toolExecuteBefore": async (input, output) => {
|
|
564
|
+
const sessionId = input?.sessionID;
|
|
565
|
+
const toolName = input?.tool ?? "";
|
|
566
|
+
const toolInput = output?.args ?? {};
|
|
567
|
+
let decision;
|
|
568
|
+
try {
|
|
569
|
+
decision = rt.routing.routePreToolUse(toolName, toolInput, rt.projectDir, rt.platform);
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
// Routing failure → allow passthrough. Previously silent; now logged
|
|
573
|
+
// (deduped) so routing config issues are visible without flooding.
|
|
574
|
+
rt.logHookError("tool.execute.before", err, sessionId);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (!decision)
|
|
578
|
+
return; // No routing match → passthrough
|
|
579
|
+
if (decision.action === "deny" || decision.action === "ask") {
|
|
580
|
+
// Throw to block — OpenCode catches this and denies the tool call
|
|
581
|
+
throw new Error(decision.reason ?? "Blocked by context-mode");
|
|
582
|
+
}
|
|
583
|
+
if (decision.action === "modify" && decision.updatedInput) {
|
|
584
|
+
// Mutate output.args — OpenCode reads the mutated output object
|
|
585
|
+
Object.assign(output.args, decision.updatedInput);
|
|
586
|
+
}
|
|
587
|
+
if (decision.action === "context" && decision.additionalContext) {
|
|
588
|
+
// Mutate output.args — OpenCode reads the mutated output object
|
|
589
|
+
output.args.additionalContext = decision.additionalContext;
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
// ── PostToolUse: Session event capture ──────────────
|
|
593
|
+
"toolExecuteAfter": async (input, output) => {
|
|
594
|
+
const sessionId = input?.sessionID;
|
|
595
|
+
if (!sessionId)
|
|
596
|
+
return;
|
|
597
|
+
try {
|
|
598
|
+
rt.db.ensureSession(sessionId, rt.projectDir);
|
|
599
|
+
// OC-4 (#487 follow-up): AGENTS.md → rule_content capture for snapshot
|
|
600
|
+
// and auto-memory parity. Idempotent per-session via Set guard.
|
|
601
|
+
rt.captureAgentsMd(sessionId);
|
|
602
|
+
const hookInput = {
|
|
603
|
+
tool_name: input.tool ?? "",
|
|
604
|
+
tool_input: input.args ?? {},
|
|
605
|
+
tool_response: output.output,
|
|
606
|
+
// v1 host never provides isError (response text alone drives
|
|
607
|
+
// error detection); the v2 execute.after bridge sets it on
|
|
608
|
+
// status:"error" so failed tool calls are captured via the SAME
|
|
609
|
+
// extractEvents path (extract.ts isToolError).
|
|
610
|
+
tool_output: output?.isError === true ? { isError: true } : undefined,
|
|
611
|
+
};
|
|
612
|
+
const events = extractEvents(hookInput);
|
|
613
|
+
for (const event of events) {
|
|
614
|
+
// Cast: extract.ts SessionEvent lacks data_hash (computed by insertEvent)
|
|
615
|
+
rt.db.insertEvent(sessionId, event, "PostToolUse");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
// Session capture must never break the tool call — but it is no
|
|
620
|
+
// longer SILENT: log detail (deduped) so e.g. SQLite disk I/O errors
|
|
621
|
+
// are diagnosable.
|
|
622
|
+
rt.logHookError("tool.execute.after", err, sessionId);
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
// ── event: per-turn token + cost capture (paid-observability) ───
|
|
626
|
+
// The generic bus `event` hook (refs/platforms/opencode/packages/plugin/
|
|
627
|
+
// src/index.ts:224) delivers every Event; we filter `message.updated`
|
|
628
|
+
// (published on each assistant-message update incl. step-finish —
|
|
629
|
+
// session.ts:673) and read tokens/cost/modelID off properties.info
|
|
630
|
+
// (assistant filter via role; refs stream.transport.ts:214-216).
|
|
631
|
+
//
|
|
632
|
+
// CAVEAT (refs processor.ts:717-718): message-level `.tokens` is the LAST
|
|
633
|
+
// step's snapshot (overwritten per step-finish), while `.cost` is
|
|
634
|
+
// cumulative for the turn. parseOpencodeUsage passes `.cost` through as
|
|
635
|
+
// native_cost_usd so the billed $ stays exact despite the token snapshot
|
|
636
|
+
// being last-step only. `message.updated` fires multiple times per turn;
|
|
637
|
+
// because tokens are a terminal snapshot and cost is cumulative, the last
|
|
638
|
+
// event for a message carries the final figures — re-emitting on each
|
|
639
|
+
// update is idempotent at the cost column and merely refreshes the
|
|
640
|
+
// last-step token telemetry. db.insertEvent both persists locally AND
|
|
641
|
+
// forwards to the platform (the TS-plugin equivalent of the .mjs
|
|
642
|
+
// attributeAndInsertEvents path).
|
|
643
|
+
"event": async (input) => {
|
|
644
|
+
let sessionId;
|
|
645
|
+
try {
|
|
646
|
+
const ev = input?.event;
|
|
647
|
+
if (!ev || ev.type !== "message.updated")
|
|
648
|
+
return;
|
|
649
|
+
sessionId = ev.properties?.info?.sessionID;
|
|
650
|
+
if (!sessionId || typeof sessionId !== "string")
|
|
651
|
+
return;
|
|
652
|
+
const counts = parseOpencodeUsage(ev);
|
|
653
|
+
if (!counts)
|
|
654
|
+
return;
|
|
655
|
+
const usageEvent = buildAgentUsageEvent(counts);
|
|
656
|
+
if (!usageEvent)
|
|
657
|
+
return;
|
|
658
|
+
rt.db.ensureSession(sessionId, rt.projectDir);
|
|
659
|
+
rt.db.insertEvent(sessionId, usageEvent, "MessageUpdated");
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
// Usage capture must never break the session — log (deduped).
|
|
663
|
+
rt.logHookError("event", err, sessionId);
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
// ── chat.message: User-prompt capture (OC-2 / Z2) ───
|
|
667
|
+
// SDK signature verified at refs/platforms/opencode/packages/plugin/src/
|
|
668
|
+
// index.ts:233. Orchestrator reference at refs/plugin-examples/opencode/
|
|
669
|
+
// opencode-orchestrator/src/plugin-handlers/chat-message-handler.ts:41-65.
|
|
670
|
+
// CCv2 inline filter: skip synthetic harness messages (system reminders,
|
|
671
|
+
// tool results, etc.) so we don't pollute the user-prompt event stream.
|
|
672
|
+
"chatMessage": async (input, output) => {
|
|
673
|
+
const sessionId = input?.sessionID;
|
|
674
|
+
if (!sessionId)
|
|
675
|
+
return;
|
|
676
|
+
try {
|
|
677
|
+
const parts = Array.isArray(output?.parts) ? output.parts : [];
|
|
678
|
+
const textPart = parts.find((p) => p && p.type === "text" && typeof p.text === "string" && p.text.length > 0);
|
|
679
|
+
if (!textPart || !textPart.text)
|
|
680
|
+
return;
|
|
681
|
+
const message = textPart.text;
|
|
682
|
+
if (isSyntheticMessage(message))
|
|
683
|
+
return;
|
|
684
|
+
rt.db.ensureSession(sessionId, rt.projectDir);
|
|
685
|
+
// OC-4 (#487 follow-up): also capture on chat.message so sessions that
|
|
686
|
+
// never invoke a tool still seed rule_content events for continuity.
|
|
687
|
+
rt.captureAgentsMd(sessionId);
|
|
688
|
+
// 1. Always save the raw prompt
|
|
689
|
+
rt.db.insertEvent(sessionId, {
|
|
690
|
+
type: "user_prompt",
|
|
691
|
+
category: "user-prompt",
|
|
692
|
+
data: message,
|
|
693
|
+
priority: 1,
|
|
694
|
+
}, "UserPromptSubmit");
|
|
695
|
+
// 2. Extract role/decision/intent/skill events from the prompt body
|
|
696
|
+
const userEvents = extractUserEvents(message);
|
|
697
|
+
for (const ev of userEvents) {
|
|
698
|
+
rt.db.insertEvent(sessionId, ev, "UserPromptSubmit");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
// chat.message must never break the turn — log (deduped).
|
|
703
|
+
rt.logHookError("chat.message", err, sessionId);
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
// ── PreCompact: Snapshot generation ─────────────────
|
|
707
|
+
"sessionCompacting": async (input, output) => {
|
|
708
|
+
const sessionId = input?.sessionID;
|
|
709
|
+
if (!sessionId)
|
|
710
|
+
return "";
|
|
711
|
+
try {
|
|
712
|
+
rt.db.ensureSession(sessionId, rt.projectDir);
|
|
713
|
+
const events = rt.db.getEvents(sessionId);
|
|
714
|
+
if (events.length === 0)
|
|
715
|
+
return "";
|
|
716
|
+
const stats = rt.db.getSessionStats(sessionId);
|
|
717
|
+
const snapshot = buildResumeSnapshot(events, {
|
|
718
|
+
compactCount: (stats?.compact_count ?? 0) + 1,
|
|
719
|
+
});
|
|
720
|
+
rt.db.upsertResume(sessionId, snapshot, events.length);
|
|
721
|
+
rt.db.incrementCompactCount(sessionId);
|
|
722
|
+
// Mutate output.context to inject the snapshot
|
|
723
|
+
output.context.push(snapshot);
|
|
724
|
+
if (process.env.OPENCODE_DEBUG) {
|
|
725
|
+
await rt.safeLog(snapshot, {
|
|
726
|
+
sessionId,
|
|
727
|
+
source: "on compaction - snapshot",
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
// OC-3 / Z3: Add budget-capped auto-injection (P1 role / P2 rules /
|
|
731
|
+
// P3 skills / P4 intent — ≤500 tokens / ~2000 chars per
|
|
732
|
+
// hooks/auto-injection.mjs). Pushed as a separate context entry so
|
|
733
|
+
// OpenCode can fold it independently from the verbose snapshot.
|
|
734
|
+
try {
|
|
735
|
+
const autoBlock = rt.autoInjectionMod.buildAutoInjection(events);
|
|
736
|
+
if (autoBlock && autoBlock.length > 0) {
|
|
737
|
+
output.context.push(autoBlock);
|
|
738
|
+
}
|
|
739
|
+
if (process.env.OPENCODE_DEBUG) {
|
|
740
|
+
await rt.safeLog(autoBlock, {
|
|
741
|
+
sessionId,
|
|
742
|
+
source: "on compaction - autoBlock",
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
// Auto-injection failure must NOT break the snapshot path — log (deduped).
|
|
748
|
+
rt.logHookError("experimental.session.compacting", err, sessionId);
|
|
749
|
+
}
|
|
750
|
+
return snapshot;
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
rt.logHookError("experimental.session.compacting", err, sessionId);
|
|
754
|
+
return "";
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
// ── SessionStart equivalent (PR #376) ───────────────
|
|
758
|
+
// OpenCode lacks a real SessionStart hook (#14808, #5409). The closest
|
|
759
|
+
// surrogate is `experimental.chat.system.transform` — verified shape:
|
|
760
|
+
// input: { sessionID?: string; model: Model }
|
|
761
|
+
// output: { system: string[] }
|
|
762
|
+
// We claim the most-recent unconsumed resume snapshot atomically (race-
|
|
763
|
+
// safe across concurrent processes) and prepend it to the system prompt.
|
|
764
|
+
"chatSystemTransform": async (input, output) => {
|
|
765
|
+
const sessionId = input?.sessionID;
|
|
766
|
+
if (!sessionId)
|
|
767
|
+
return;
|
|
768
|
+
// ── OC-1 / CCv1: ROUTING_BLOCK injection ──────────────
|
|
769
|
+
// Inject the <context_window_protection> XML block on the first
|
|
770
|
+
// chat.system.transform per session. This is INDEPENDENT of the
|
|
771
|
+
// resume snapshot path below — routing block must fire even when
|
|
772
|
+
// no prior session row exists. Splice at index 1 (NOT unshift) for
|
|
773
|
+
// the same OpenCode llm.ts:117-128 cache-fold reason as resume.
|
|
774
|
+
//
|
|
775
|
+
// Skip injection when system prompt already contains context-mode
|
|
776
|
+
// routing rules (e.g. via AGENTS.md / CLAUDE.md loaded by the host).
|
|
777
|
+
// Detect by checking for a quorum of distinctive tool names — any two
|
|
778
|
+
// of ctx_execute, ctx_batch_execute, ctx_fetch_and_index confirms the
|
|
779
|
+
// instructions are present and avoids ~2K chars of duplication.
|
|
780
|
+
if (Array.isArray(output?.system)) {
|
|
781
|
+
if (!systemHasRoutingInstructions(output.system)) {
|
|
782
|
+
try {
|
|
783
|
+
output.system.splice(1, 0, rt.routingBlock);
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
// Never break the chat turn on routing-block injection failure.
|
|
787
|
+
}
|
|
788
|
+
if (process.env.OPENCODE_DEBUG) {
|
|
789
|
+
await rt.safeLog(output.system[1], { sessionId, source: 'on routing block injection' });
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else if (process.env.OPENCODE_DEBUG) {
|
|
793
|
+
await rt.safeLog(`routing block skipped — system prompt already contains context-mode instructions`, { sessionId, source: 'on routing block injection' });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
// Pass current sessionId so SQL excludes self-injection (v1.0.106 — Mickey #376
|
|
798
|
+
// follow-up): if Session B compacts mid-flight and produces its own row,
|
|
799
|
+
// B's next system.transform must NOT claim that row back into B's prompt.
|
|
800
|
+
const row = rt.db.claimLatestUnconsumedResume(sessionId);
|
|
801
|
+
if (!row || !row.snapshot)
|
|
802
|
+
return; // no row → retry on next turn
|
|
803
|
+
if (process.env.OPENCODE_DEBUG) {
|
|
804
|
+
await rt.safeLog(row.snapshot, {
|
|
805
|
+
sessionId,
|
|
806
|
+
source: "on resume - snapshot",
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
if (Array.isArray(output?.system)) {
|
|
810
|
+
// Insert at index 1 (after the header) — NOT unshift.
|
|
811
|
+
// OpenCode's llm.ts:117-128 saves `header = system[0]` BEFORE this
|
|
812
|
+
// hook runs and then folds the rest into a 2-part structure
|
|
813
|
+
// `[header, body]` only if `system[0] === header` after the hook.
|
|
814
|
+
// Prepending via unshift replaces system[0] with the snapshot,
|
|
815
|
+
// making the equality check fail → cache-fold is skipped → every
|
|
816
|
+
// system block is sent as a separate `role: "system"` message →
|
|
817
|
+
// provider prompt cache is invalidated on every resume injection.
|
|
818
|
+
// Inserting at index 1 keeps the header invariant and lets the
|
|
819
|
+
// snapshot ride along inside the cached body block.
|
|
820
|
+
output.system.splice(1, 0, row.snapshot);
|
|
821
|
+
// Mark consumed only AFTER successful splice so failed paths can retry
|
|
822
|
+
if (process.env.OPENCODE_DEBUG) {
|
|
823
|
+
await rt.safeLog(output.system[1], { sessionId, source: "on resume" });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
// Never break the chat turn — but log detail (deduped) so DB-level
|
|
829
|
+
// failures (e.g. disk I/O) are diagnosable.
|
|
830
|
+
rt.logHookError("experimental.chat.system.transform", err, sessionId);
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
/** Normalize an unconfirmed host return value into a dispose handle. */
|
|
836
|
+
function normalizeDisposeHandle(ret) {
|
|
837
|
+
if (typeof ret === "function")
|
|
838
|
+
return ret;
|
|
839
|
+
if (ret !== null && typeof ret === "object") {
|
|
840
|
+
const obj = ret;
|
|
841
|
+
for (const key of ["dispose", "unregister", "stop", "off", "close"]) {
|
|
842
|
+
const candidate = obj[key];
|
|
843
|
+
if (typeof candidate === "function") {
|
|
844
|
+
return candidate.bind(obj);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return undefined;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Tolerant registration call against an unconfirmed host function. Unlike a
|
|
852
|
+
* boolean probe, the returned registration/dispose handle (if any) is
|
|
853
|
+
* CAPTURED so failed/cleaned-up setups can unregister everything they
|
|
854
|
+
* registered — no live partial registrations, no double-registered handlers
|
|
855
|
+
* after a reload.
|
|
856
|
+
*/
|
|
857
|
+
async function tryRegister(fn, thisArg, ...args) {
|
|
858
|
+
if (typeof fn !== "function")
|
|
859
|
+
return { ok: false };
|
|
860
|
+
try {
|
|
861
|
+
const ret = await fn.call(thisArg, ...args);
|
|
862
|
+
return { ok: true, dispose: normalizeDisposeHandle(ret) };
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
return { ok: false };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/** Chain several dispose handles into one (executed in reverse order). */
|
|
869
|
+
function chainDisposes(disposes) {
|
|
870
|
+
return async () => {
|
|
871
|
+
for (const dispose of [...disposes].reverse()) {
|
|
872
|
+
try {
|
|
873
|
+
await dispose();
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
// Best-effort unregistration.
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Tear down a v2 activation: mark the runtime closed, unregister everything
|
|
883
|
+
* registered (reverse order, best-effort), close the runtime DB handle (no
|
|
884
|
+
* leak across setup failure / reload), then the caller releases the registry
|
|
885
|
+
* claim.
|
|
886
|
+
*
|
|
887
|
+
* HOST LIMITATION (defense-in-depth): hosts whose registration calls succeed
|
|
888
|
+
* but return NO dispose handle cannot be fully unregistered from here. The
|
|
889
|
+
* liveness flag below is set FIRST — before any dispose and before the claim
|
|
890
|
+
* is released — so every v2-registered callback becomes a silent no-op: a
|
|
891
|
+
* stale handle-less callback can never touch the closed DB or double-fire
|
|
892
|
+
* next to a later v1 activation for the same project.
|
|
893
|
+
*/
|
|
894
|
+
async function teardownV2(disposes, rt) {
|
|
895
|
+
// Liveness FIRST — see the host-limitation note above.
|
|
896
|
+
if (rt)
|
|
897
|
+
rt.closed = true;
|
|
898
|
+
for (const dispose of [...disposes].reverse()) {
|
|
899
|
+
try {
|
|
900
|
+
await dispose();
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
// Best-effort unregistration — a hostile/absent surface must not
|
|
904
|
+
// prevent the DB close or claim release below.
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
try {
|
|
908
|
+
rt?.db?.close();
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
// Best-effort DB close (Lane B owns SessionDB.close semantics).
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Sync the process-global routing availability signal (hooks/core/routing.mjs
|
|
916
|
+
* `setContextModeToolsAvailable`) with the activation registry: TRUE when
|
|
917
|
+
* any activation in this process has CONFIRMED native ctx_* tools (v2
|
|
918
|
+
* native claim) — routing may then emit redirect/deny guidance pointing at
|
|
919
|
+
* ctx_* tools even without an MCP server (they ARE registered natively in
|
|
920
|
+
* this process). FALSE otherwise — the default; v1 never touches the flag
|
|
921
|
+
* and keeps gating on the MCP readiness sentinel exactly as before, so
|
|
922
|
+
* every other platform's behavior is unchanged.
|
|
923
|
+
*
|
|
924
|
+
* Best-effort: a missing setter (older routing copy) or a setter failure
|
|
925
|
+
* must never break setup/teardown.
|
|
926
|
+
*/
|
|
927
|
+
function syncRoutingNativeToolsFlag(rt, state) {
|
|
928
|
+
const setFn = rt?.routing?.setContextModeToolsAvailable;
|
|
929
|
+
if (typeof setFn !== "function")
|
|
930
|
+
return;
|
|
931
|
+
try {
|
|
932
|
+
setFn([...state.activations.values()].some((entry) => entry.nativeToolsConfirmed === true));
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
// Best-effort — enforcement must never break on a signal error.
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Resolve the project directory from an unconfirmed v2 setup context.
|
|
940
|
+
* Probes ctx.directory (v1 parity) then ctx.project.directory, falling back
|
|
941
|
+
* to process.cwd() like the v1 path.
|
|
942
|
+
*/
|
|
943
|
+
function resolveV2ProjectDir(ctx) {
|
|
944
|
+
const dir = ctx?.directory ??
|
|
945
|
+
ctx?.project?.directory;
|
|
946
|
+
return resolve(typeof dir === "string" && dir.length > 0 ? dir : process.cwd());
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* v2 execute-hook bridges around the shared handlers. Each entry point
|
|
950
|
+
* checks the runtime liveness flag FIRST: after a teardown (failed setup or
|
|
951
|
+
* cleanup), any callback the host kept despite a missing dispose handle
|
|
952
|
+
* becomes a silent no-op — no throw, no DB touch — even if a v1 activation
|
|
953
|
+
* later claims the same project.
|
|
954
|
+
*
|
|
955
|
+
* execute.before event { tool, input } (input INSPECTABLE/REPLACEABLE):
|
|
956
|
+
* the shared v1 handler mutates `output.args` in place; we pass
|
|
957
|
+
* `output.args === event.input` (same object) so replacements propagate to
|
|
958
|
+
* the host. A deny decision throws — the host decides how to block.
|
|
959
|
+
*
|
|
960
|
+
* execute.after event { status: "completed"|"error", result?, error? }
|
|
961
|
+
* (extra fields UNCONFIRMED — accessed defensively): completed results feed
|
|
962
|
+
* the shared capture handler; ERROR status ALSO captures the failed tool
|
|
963
|
+
* call (error_tool events via the shared extractEvents path, with an
|
|
964
|
+
* explicit isError flag — richer than v1) alongside the deduped log.
|
|
965
|
+
*/
|
|
966
|
+
function createV2ExecuteHookBridges(rt, handlers) {
|
|
967
|
+
return {
|
|
968
|
+
before: async (event) => {
|
|
969
|
+
if (rt.closed)
|
|
970
|
+
return undefined; // torn down — silent no-op
|
|
971
|
+
const ev = (event ?? {});
|
|
972
|
+
if (ev.input === null || typeof ev.input !== "object")
|
|
973
|
+
ev.input = {};
|
|
974
|
+
const v1Input = {
|
|
975
|
+
tool: v2ToolNameOf(ev),
|
|
976
|
+
sessionID: v2SessionIdOf(ev),
|
|
977
|
+
callID: typeof ev.callID === "string" ? ev.callID : "",
|
|
978
|
+
};
|
|
979
|
+
// Same-object mutation target: output.args IS event.input.
|
|
980
|
+
const v1Output = { args: ev.input };
|
|
981
|
+
await handlers.toolExecuteBefore(v1Input, v1Output);
|
|
982
|
+
return ev.input;
|
|
983
|
+
},
|
|
984
|
+
after: async (event) => {
|
|
985
|
+
if (rt.closed)
|
|
986
|
+
return undefined; // torn down — silent no-op
|
|
987
|
+
const ev = (event ?? {});
|
|
988
|
+
const sessionId = v2SessionIdOf(ev);
|
|
989
|
+
const toolName = v2ToolNameOf(ev);
|
|
990
|
+
const v1Input = {
|
|
991
|
+
tool: toolName,
|
|
992
|
+
sessionID: sessionId,
|
|
993
|
+
callID: typeof ev.callID === "string" ? ev.callID : "",
|
|
994
|
+
args: ev.input ?? {},
|
|
995
|
+
};
|
|
996
|
+
if (ev.status === "error") {
|
|
997
|
+
// FAILED tool call — still CAPTURED, not dropped: v1 produces
|
|
998
|
+
// error_tool events through extractEvents/isToolError (which reads
|
|
999
|
+
// an explicit isError flag, extract.ts:117-121). v2's status gives
|
|
1000
|
+
// us that flag directly — richer than v1 (which only sees
|
|
1001
|
+
// error-ish Bash response text) and the SAME shared extraction
|
|
1002
|
+
// path. The diagnosable log is preserved alongside the capture.
|
|
1003
|
+
rt.logHookError("v2.tool.execute.after", ev.error ?? new Error("tool execute failed"), sessionId);
|
|
1004
|
+
const v1ErrorOutput = {
|
|
1005
|
+
title: toolName,
|
|
1006
|
+
output: extractV2ToolErrorText(ev.error),
|
|
1007
|
+
metadata: undefined,
|
|
1008
|
+
isError: true,
|
|
1009
|
+
};
|
|
1010
|
+
await handlers.toolExecuteAfter(v1Input, v1ErrorOutput);
|
|
1011
|
+
return undefined;
|
|
1012
|
+
}
|
|
1013
|
+
const v1Output = {
|
|
1014
|
+
title: toolName,
|
|
1015
|
+
output: extractV2ToolResultText(ev.result),
|
|
1016
|
+
metadata: ev.result && typeof ev.result === "object" ? ev.result.metadata : undefined,
|
|
1017
|
+
};
|
|
1018
|
+
await handlers.toolExecuteAfter(v1Input, v1Output);
|
|
1019
|
+
return ev.result;
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Register the MANDATORY execute-hook bridges via ctx.tool.hook
|
|
1025
|
+
* ("execute.before" / "execute.after" — verified v2 names). Returns which
|
|
1026
|
+
* hooks registered and the dispose handles for everything registered.
|
|
1027
|
+
*/
|
|
1028
|
+
async function registerToolExecuteHooksV2(ctx, rt, handlers) {
|
|
1029
|
+
const tool = ctx?.tool ?? {};
|
|
1030
|
+
const result = { before: false, after: false, via: "", disposes: [] };
|
|
1031
|
+
const hookFn = typeof tool.hook === "function" ? tool.hook : undefined;
|
|
1032
|
+
if (!hookFn)
|
|
1033
|
+
return result;
|
|
1034
|
+
const bridges = createV2ExecuteHookBridges(rt, handlers);
|
|
1035
|
+
const beforeReg = await tryRegister(hookFn, tool, "execute.before", bridges.before);
|
|
1036
|
+
if (beforeReg.ok) {
|
|
1037
|
+
result.before = true;
|
|
1038
|
+
if (beforeReg.dispose)
|
|
1039
|
+
result.disposes.push(beforeReg.dispose);
|
|
1040
|
+
}
|
|
1041
|
+
const afterReg = await tryRegister(hookFn, tool, "execute.after", bridges.after);
|
|
1042
|
+
if (afterReg.ok) {
|
|
1043
|
+
result.after = true;
|
|
1044
|
+
if (afterReg.dispose)
|
|
1045
|
+
result.disposes.push(afterReg.dispose);
|
|
1046
|
+
}
|
|
1047
|
+
if (result.before && result.after)
|
|
1048
|
+
result.via = "ctx.tool.hook(execute.before/after)";
|
|
1049
|
+
return result;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* OPTIONAL: register the session "context" hook that bridges the v1
|
|
1053
|
+
* experimental.chat.system.transform behavior (routing block + resume
|
|
1054
|
+
* snapshot injection). v2 delivers `event.system: SystemPart[]` (MUTABLE,
|
|
1055
|
+
* push {text}) and the hook "runs again for compaction" — covering the v1
|
|
1056
|
+
* compaction-injection flows too. The v1 shared handler splices STRINGS at
|
|
1057
|
+
* index 1 of a string array, so we: (1) build a string view of the parts,
|
|
1058
|
+
* (2) run the shared handler on that view, (3) reconcile back — original
|
|
1059
|
+
* part objects are preserved positionally (content-equality match) and
|
|
1060
|
+
* INSERTED strings become `{ type: "text", text }` parts — required by the
|
|
1061
|
+
* runtime schema (LLM.SystemPart), even though the docs example shows bare
|
|
1062
|
+
* `{ text }`. This keeps
|
|
1063
|
+
* any extra part fields (e.g. provider cache hints) intact.
|
|
1064
|
+
* Returns ok=false when the surface is absent or registration fails.
|
|
1065
|
+
*/
|
|
1066
|
+
async function registerSessionContextV2(ctx, rt, handlers) {
|
|
1067
|
+
const sessionHook = ctx?.session && typeof ctx.session.hook === "function" ? ctx.session.hook : undefined;
|
|
1068
|
+
if (!sessionHook)
|
|
1069
|
+
return { ok: false };
|
|
1070
|
+
const handler = async (event) => {
|
|
1071
|
+
if (rt.closed)
|
|
1072
|
+
return undefined; // torn down — silent no-op
|
|
1073
|
+
const ev = (event ?? {});
|
|
1074
|
+
const sessionId = ev.sessionID ?? ev.session?.id;
|
|
1075
|
+
if (!sessionId || !Array.isArray(ev.system))
|
|
1076
|
+
return undefined; // cannot attribute — honest no-op
|
|
1077
|
+
const originalParts = ev.system.slice();
|
|
1078
|
+
const v1System = originalParts.map(v2SystemPartText);
|
|
1079
|
+
await handlers.chatSystemTransform({ sessionID: sessionId, model: ev.model ?? {} }, { system: v1System });
|
|
1080
|
+
// Reconcile the (possibly grown) string array back onto event.system.
|
|
1081
|
+
const rebuilt = [];
|
|
1082
|
+
let oi = 0;
|
|
1083
|
+
for (const text of v1System) {
|
|
1084
|
+
if (oi < originalParts.length && v2SystemPartText(originalParts[oi]) === text) {
|
|
1085
|
+
rebuilt.push(originalParts[oi]); // unchanged — keep the original part object
|
|
1086
|
+
oi += 1;
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
rebuilt.push({ type: "text", text }); // inserted by the shared handler (routing block / snapshot); runtime schema (LLM.SystemPart) requires type:"text"
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
ev.system.length = 0;
|
|
1093
|
+
for (const part of rebuilt)
|
|
1094
|
+
ev.system.push(part);
|
|
1095
|
+
return ev.system;
|
|
1096
|
+
};
|
|
1097
|
+
return tryRegister(sessionHook, ctx?.session, "context", handler);
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* OPTIONAL: register the session "prompt" hook — the v1 chat.message
|
|
1101
|
+
* equivalent for user-prompt capture (event.prompt.text). Synthetic-harness
|
|
1102
|
+
* filtering and event extraction happen in the shared handler.
|
|
1103
|
+
*/
|
|
1104
|
+
async function registerSessionPromptV2(ctx, rt, handlers) {
|
|
1105
|
+
const sessionHook = ctx?.session && typeof ctx.session.hook === "function" ? ctx.session.hook : undefined;
|
|
1106
|
+
if (!sessionHook)
|
|
1107
|
+
return { ok: false };
|
|
1108
|
+
const handler = async (event) => {
|
|
1109
|
+
if (rt.closed)
|
|
1110
|
+
return undefined; // torn down — silent no-op
|
|
1111
|
+
const ev = (event ?? {});
|
|
1112
|
+
const prompt = ev.prompt;
|
|
1113
|
+
const text = typeof prompt === "string" ? prompt : prompt && typeof prompt === "object" ? prompt.text : undefined;
|
|
1114
|
+
if (typeof text !== "string" || text.length === 0)
|
|
1115
|
+
return undefined;
|
|
1116
|
+
const sessionId = v2SessionIdOf(ev);
|
|
1117
|
+
if (!sessionId)
|
|
1118
|
+
return undefined; // cannot attribute capture — honest no-op
|
|
1119
|
+
const v1Input = {
|
|
1120
|
+
sessionID: sessionId,
|
|
1121
|
+
agent: typeof ev.agent === "string" ? ev.agent : undefined,
|
|
1122
|
+
messageID: typeof ev.metadata?.messageID === "string" ? ev.metadata.messageID : undefined,
|
|
1123
|
+
};
|
|
1124
|
+
const v1Output = {
|
|
1125
|
+
message: prompt ?? {},
|
|
1126
|
+
parts: [{ type: "text", text }],
|
|
1127
|
+
};
|
|
1128
|
+
await handlers.chatMessage(v1Input, v1Output);
|
|
1129
|
+
return undefined;
|
|
1130
|
+
};
|
|
1131
|
+
return tryRegister(sessionHook, ctx?.session, "prompt", handler);
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* OPTIONAL: subscribe to the v2 event bus — ctx.event.subscribe({ signal })
|
|
1135
|
+
* returns an AsyncIterable of { type, ... } events (exact shapes
|
|
1136
|
+
* UNCONFIRMED — handled generically). Events are fed to the shared event
|
|
1137
|
+
* handler in the closest v1 mapping: { event: { type, properties } } where
|
|
1138
|
+
* properties falls back to the whole event object; the shared handler
|
|
1139
|
+
* filters non-usage events itself. The pump runs DETACHED (subscribing
|
|
1140
|
+
* synchronously returns after the registration); cleanup aborts the
|
|
1141
|
+
* controller, which ends the iteration on the next event or waiter wake-up.
|
|
1142
|
+
*/
|
|
1143
|
+
async function registerEventBusV2(ctx, rt, handlers) {
|
|
1144
|
+
const eventSurface = ctx?.event;
|
|
1145
|
+
const subscribe = eventSurface && typeof eventSurface.subscribe === "function" ? eventSurface.subscribe : undefined;
|
|
1146
|
+
if (!subscribe || !eventSurface)
|
|
1147
|
+
return { ok: false };
|
|
1148
|
+
const controller = new AbortController();
|
|
1149
|
+
let stream;
|
|
1150
|
+
try {
|
|
1151
|
+
stream = await Promise.resolve(subscribe.call(eventSurface, { signal: controller.signal }));
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
return { ok: false };
|
|
1155
|
+
}
|
|
1156
|
+
const handler = (raw) => {
|
|
1157
|
+
if (rt.closed)
|
|
1158
|
+
return Promise.resolve(); // torn down — silent no-op
|
|
1159
|
+
const ev = (raw ?? {});
|
|
1160
|
+
// Generic mapping: the shared handler filters by type and reads
|
|
1161
|
+
// properties defensively — a shape it does not understand is a no-op.
|
|
1162
|
+
return handlers.event({ event: { type: ev.type, properties: ev.properties ?? ev } });
|
|
1163
|
+
};
|
|
1164
|
+
const pump = (async () => {
|
|
1165
|
+
const iterable = stream;
|
|
1166
|
+
if (!iterable ||
|
|
1167
|
+
typeof iterable[Symbol.asyncIterator] !== "function") {
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
for await (const raw of iterable) {
|
|
1171
|
+
if (rt.closed || controller.signal.aborted)
|
|
1172
|
+
break;
|
|
1173
|
+
try {
|
|
1174
|
+
await handler(raw);
|
|
1175
|
+
}
|
|
1176
|
+
catch (err) {
|
|
1177
|
+
rt.logHookError("v2.event", err);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
})().catch(() => {
|
|
1181
|
+
// Aborted or the host stream errored — best-effort capture only.
|
|
1182
|
+
});
|
|
1183
|
+
const abortDispose = () => {
|
|
1184
|
+
// Abort is the contract (host ends the stream); the pump is NOT awaited
|
|
1185
|
+
// here — hosts may only notice the abort on the next event, and teardown
|
|
1186
|
+
// must never hang on that.
|
|
1187
|
+
controller.abort();
|
|
1188
|
+
};
|
|
1189
|
+
const hostDispose = normalizeDisposeHandle(stream);
|
|
1190
|
+
return {
|
|
1191
|
+
ok: true,
|
|
1192
|
+
dispose: hostDispose ? chainDisposes([abortDispose, hostDispose]) : abortDispose,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* MANDATORY: register the native ctx_* tools via ctx.tool.transform(editor)
|
|
1197
|
+
* (verified v2 API). The ToolEditor receives one ToolInfo per ctx_* tool:
|
|
1198
|
+
* { name, description, input: <JSON Schema from the SAME Zod schema the
|
|
1199
|
+
* MCP layer registers>, execute }
|
|
1200
|
+
* NO namespace is set so effective tool names stay `ctx_*` — identical to
|
|
1201
|
+
* the v1 native path. Every registered execute is liveness-wrapped so a
|
|
1202
|
+
* tool the host kept after teardown becomes a harmless no-op. ok=false
|
|
1203
|
+
* means the MCP fallback (mcp.context-mode) remains the tool provider and —
|
|
1204
|
+
* per the activation policy — v2 must NOT claim activation.
|
|
1205
|
+
*/
|
|
1206
|
+
async function registerNativeToolsV2(ctx, rt) {
|
|
1207
|
+
const transformFn = ctx?.tool && typeof ctx.tool.transform === "function" ? ctx.tool.transform : undefined;
|
|
1208
|
+
if (!transformFn)
|
|
1209
|
+
return { ok: false };
|
|
1210
|
+
let tools;
|
|
1211
|
+
try {
|
|
1212
|
+
// Same source v1 uses: REGISTERED_CTX_TOOLS via ../../server.js, with
|
|
1213
|
+
// the same Zod-preprocessing execute path (buildNativeTools).
|
|
1214
|
+
tools = await rt.buildNativeTools();
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
// Native tool bridge failed to build — MCP fallback remains.
|
|
1218
|
+
return { ok: false };
|
|
1219
|
+
}
|
|
1220
|
+
const infos = Object.entries(tools).map(([name, def]) => ({
|
|
1221
|
+
name,
|
|
1222
|
+
description: def.description,
|
|
1223
|
+
input: def.inputJsonSchema ?? {},
|
|
1224
|
+
// codemode:false exposes each tool as an individually callable tool.
|
|
1225
|
+
// With the default (codemode enabled) the v2 executor folds tools into
|
|
1226
|
+
// its single `execute` CodeMode tool, so a direct `ctx_stats` call
|
|
1227
|
+
// fails with "Unknown tool" (verified against the v2 executor).
|
|
1228
|
+
options: { codemode: false },
|
|
1229
|
+
execute: async (input, tool) => {
|
|
1230
|
+
if (rt.closed) {
|
|
1231
|
+
return { content: "context-mode: inactive (plugin setup was torn down)" };
|
|
1232
|
+
}
|
|
1233
|
+
try {
|
|
1234
|
+
// Defensive metadata hook (UNCONFIRMED whether v2 exposes one).
|
|
1235
|
+
const metadata = tool?.metadata;
|
|
1236
|
+
if (typeof metadata === "function") {
|
|
1237
|
+
try {
|
|
1238
|
+
metadata.call(tool, { title: name });
|
|
1239
|
+
}
|
|
1240
|
+
catch {
|
|
1241
|
+
// Metadata is advisory — never fail the call over it.
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
// Reuse the v1 execute path verbatim: same Zod preprocessing,
|
|
1245
|
+
// same withProjectDirOverride capture attribution, same error
|
|
1246
|
+
// semantics. Tool ctx fields are UNCONFIRMED on v2 — defaults keep
|
|
1247
|
+
// the handler working with the plugin's own project dir.
|
|
1248
|
+
const toolCtx = (tool ?? {});
|
|
1249
|
+
const result = await def.execute((input ?? {}), {
|
|
1250
|
+
sessionID: toolCtx.sessionID ?? "",
|
|
1251
|
+
messageID: toolCtx.messageID ?? "",
|
|
1252
|
+
agent: toolCtx.agent ?? "v2",
|
|
1253
|
+
directory: toolCtx.directory || rt.projectDir,
|
|
1254
|
+
worktree: toolCtx.worktree,
|
|
1255
|
+
abort: toolCtx.abort,
|
|
1256
|
+
metadata: typeof metadata === "function" ? metadata.bind(tool) : undefined,
|
|
1257
|
+
});
|
|
1258
|
+
const text = typeof result === "string"
|
|
1259
|
+
? result
|
|
1260
|
+
: result && typeof result === "object" && typeof result.output === "string"
|
|
1261
|
+
? result.output
|
|
1262
|
+
: JSON.stringify(result ?? "");
|
|
1263
|
+
return { content: text };
|
|
1264
|
+
}
|
|
1265
|
+
catch (err) {
|
|
1266
|
+
// Surface the failure to the host AND log it (deduped) so e.g.
|
|
1267
|
+
// Zod validation issues are diagnosable.
|
|
1268
|
+
rt.logHookError(`v2.tool.${name}`, err);
|
|
1269
|
+
throw err;
|
|
1270
|
+
}
|
|
1271
|
+
},
|
|
1272
|
+
}));
|
|
1273
|
+
// Single transform callback registers every tool; the returned
|
|
1274
|
+
// Registration is captured as the dispose handle for teardown.
|
|
1275
|
+
return tryRegister(transformFn, ctx?.tool, (editor) => {
|
|
1276
|
+
const add = typeof editor?.add === "function" ? editor.add : undefined;
|
|
1277
|
+
if (typeof add !== "function") {
|
|
1278
|
+
throw new Error("v2 ToolEditor.add unavailable");
|
|
1279
|
+
}
|
|
1280
|
+
for (const info of infos) {
|
|
1281
|
+
add.call(editor, info);
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Bookkeeping helper for OPTIONAL v2 registrations (finding: single
|
|
1287
|
+
* attempt → capability flag → one-time degradation log). No behavior
|
|
1288
|
+
* change vs the previous inline branches — just one code path.
|
|
1289
|
+
*
|
|
1290
|
+
* Returns the registration status ("registered" | "unavailable") for the
|
|
1291
|
+
* setup summary line plus the dispose handles to tear down later.
|
|
1292
|
+
*/
|
|
1293
|
+
async function attemptOptionalV2(rt, opts) {
|
|
1294
|
+
if (!opts.surfacePresent) {
|
|
1295
|
+
rt.logOnce(opts.logKeyMissing, opts.missingMessage, "warn");
|
|
1296
|
+
for (const cap of opts.degradeCapabilities ?? [])
|
|
1297
|
+
rt.adapter.markCapabilityDegraded(cap);
|
|
1298
|
+
return { status: "unavailable", disposes: [] };
|
|
1299
|
+
}
|
|
1300
|
+
const reg = await opts.register();
|
|
1301
|
+
if (reg.ok) {
|
|
1302
|
+
return { status: "registered", disposes: reg.dispose ? [reg.dispose] : [] };
|
|
1303
|
+
}
|
|
1304
|
+
if (opts.logKeyFailed && opts.failedMessage) {
|
|
1305
|
+
rt.logOnce(opts.logKeyFailed, opts.failedMessage, "warn");
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
rt.logOnce(opts.logKeyMissing, opts.missingMessage, "warn");
|
|
1309
|
+
}
|
|
1310
|
+
for (const cap of opts.degradeCapabilities ?? [])
|
|
1311
|
+
rt.adapter.markCapabilityDegraded(cap);
|
|
1312
|
+
return { status: "unavailable", disposes: [] };
|
|
1313
|
+
}
|
|
1314
|
+
// ── Plugin Factory (v1 — server(input)) ───────────────────
|
|
1315
|
+
/**
|
|
1316
|
+
* Plugin factory. Called once when a v1 host (KiloCode/OpenCode ≤ v1) loads
|
|
1317
|
+
* the plugin. Returns an object mapping hook event names to async handler
|
|
1318
|
+
* functions over the shared runtime.
|
|
1319
|
+
*
|
|
1320
|
+
* Hybrid-host guard: if the v2 setup() path (or another v1 server() call)
|
|
1321
|
+
* already claimed activation for this project, this returns an EMPTY
|
|
1322
|
+
* registration — no hooks, no DB init — and logs once.
|
|
1323
|
+
*
|
|
1324
|
+
* KiloCode expects: export default { id: string, server: (input) => Promise<Hooks> }
|
|
1325
|
+
* OpenCode expects: export const ContextModePlugin = (ctx) => Promise<Hooks>
|
|
1326
|
+
*/
|
|
1327
|
+
async function createContextModePlugin(ctx) {
|
|
1328
|
+
const state = getPluginGlobalState();
|
|
1329
|
+
const projectKey = normalizeProjectKey(ctx?.directory ?? process.cwd());
|
|
1330
|
+
// Hybrid-host guard — duplicate activation → noop registration.
|
|
1331
|
+
const existing = state.activations.get(projectKey);
|
|
1332
|
+
if (existing) {
|
|
1333
|
+
logOnceImpl(state, ctx, `duplicate-activation:${projectKey}`, `context-mode already active via ${existing.flavor} — duplicate v1 registration is a noop for this project`, "info");
|
|
1334
|
+
return { tool: {} };
|
|
1335
|
+
}
|
|
1336
|
+
// v1 native tools are CONFIRMED by construction: server() always returns
|
|
1337
|
+
// the full native tool map (buildNativeTools below). Recorded so the
|
|
1338
|
+
// adapter's legacy-MCP-removal policy can trust this claimant in-process.
|
|
1339
|
+
state.activations.set(projectKey, {
|
|
1340
|
+
flavor: "v1",
|
|
1341
|
+
claimedAt: Date.now(),
|
|
1342
|
+
nativeToolsConfirmed: true,
|
|
1343
|
+
});
|
|
1344
|
+
try {
|
|
1345
|
+
const rt = await createPluginRuntime(ctx);
|
|
1346
|
+
const handlers = createSharedHandlers(rt);
|
|
1347
|
+
const nativeTools = await rt.buildNativeTools();
|
|
1348
|
+
return {
|
|
1349
|
+
tool: nativeTools,
|
|
1350
|
+
// ── PreToolUse: Routing enforcement ───────────────
|
|
1351
|
+
"tool.execute.before": handlers.toolExecuteBefore,
|
|
1352
|
+
// ── PostToolUse: Session event capture ────────────
|
|
1353
|
+
"tool.execute.after": handlers.toolExecuteAfter,
|
|
1354
|
+
// ── event: per-turn token + cost capture ──────────
|
|
1355
|
+
event: handlers.event,
|
|
1356
|
+
// ── chat.message: User-prompt capture (OC-2 / Z2) ─
|
|
1357
|
+
"chat.message": handlers.chatMessage,
|
|
1358
|
+
// ── PreCompact: Snapshot generation ───────────────
|
|
1359
|
+
"experimental.session.compacting": handlers.sessionCompacting,
|
|
1360
|
+
// ── SessionStart equivalent (PR #376) ─────────────
|
|
1361
|
+
"experimental.chat.system.transform": handlers.chatSystemTransform,
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
catch (err) {
|
|
1365
|
+
// Release the claim so a retry (or the other flavor) can activate.
|
|
1366
|
+
state.activations.delete(projectKey);
|
|
1367
|
+
throw err;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
// ── Plugin Factory (v2 — setup(ctx)) ──────────────────────
|
|
1371
|
+
/**
|
|
1372
|
+
* v2 plugin setup. Registers the v1-equivalent behaviors against whatever
|
|
1373
|
+
* v2 surfaces the host exposes (runtime capability probing) and honors the
|
|
1374
|
+
* hybrid-host activation guard.
|
|
1375
|
+
*
|
|
1376
|
+
* ACTIVATION POLICY: v2 may claim ONLY when the FULL mandatory surface
|
|
1377
|
+
* succeeds — tool execute before/after hooks AND native ctx_* tool
|
|
1378
|
+
* registration. If native tools cannot be registered, v2 does NOT claim
|
|
1379
|
+
* (one-time degraded-reason log, MCP fallback retained) so a v1 server()
|
|
1380
|
+
* entry can still claim for this project; on pure v2 hosts the same path
|
|
1381
|
+
* yields honest degraded mode with mcp.context-mode retained. Optional
|
|
1382
|
+
* capabilities (session context, event bus) degrade with one-time logs —
|
|
1383
|
+
* never a fake success.
|
|
1384
|
+
*
|
|
1385
|
+
* Returns a cleanup function (where the host supports one) that unregisters
|
|
1386
|
+
* everything registered, closes the runtime DB handle, and releases the
|
|
1387
|
+
* activation claim so a plugin reload can re-claim without double-registering
|
|
1388
|
+
* or leaking the old DB.
|
|
1389
|
+
*/
|
|
1390
|
+
async function setupV2(ctx) {
|
|
1391
|
+
const state = getPluginGlobalState();
|
|
1392
|
+
const projectDir = resolveV2ProjectDir(ctx);
|
|
1393
|
+
const projectKey = normalizeProjectKey(projectDir);
|
|
1394
|
+
// Hybrid-host guard — duplicate activation → noop + one-time log.
|
|
1395
|
+
const existing = state.activations.get(projectKey);
|
|
1396
|
+
if (existing) {
|
|
1397
|
+
logOnceImpl(state, ctx, `duplicate-activation:${projectKey}`, `context-mode already active via ${existing.flavor} — duplicate v2 setup is a noop for this project`, "info");
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
// Mandatory capability probes: BOTH native-tool (ctx.tool.transform) and
|
|
1401
|
+
// execute-hook (ctx.tool.hook) surfaces must exist BEFORE we claim
|
|
1402
|
+
// activation — native tools provide the ctx_* commands (the MCP fallback
|
|
1403
|
+
// must stay intact otherwise) and execute hooks provide routing enforcement.
|
|
1404
|
+
const hasToolTransform = typeof ctx?.tool?.transform === "function";
|
|
1405
|
+
const hasToolHookSurface = typeof ctx?.tool?.hook === "function";
|
|
1406
|
+
if (!hasToolTransform || !hasToolHookSurface) {
|
|
1407
|
+
logOnceImpl(state, ctx, `v2-no-tool-surface:${projectKey}`, "context-mode v2 setup: no native v2 tool surface (ctx.tool.transform / ctx.tool.hook) — activation not claimed; ctx_* tools remain available via the MCP fallback (mcp.context-mode)", "info");
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
// Claim early so concurrent/second entries noop while setup is in flight;
|
|
1411
|
+
// the claim is RELEASED below unless the full mandatory surface succeeds.
|
|
1412
|
+
state.activations.set(projectKey, {
|
|
1413
|
+
flavor: "v2",
|
|
1414
|
+
claimedAt: Date.now(),
|
|
1415
|
+
nativeToolsConfirmed: false,
|
|
1416
|
+
});
|
|
1417
|
+
const disposes = [];
|
|
1418
|
+
let rt;
|
|
1419
|
+
/** Failure path: unregister everything, close the DB, release the claim. */
|
|
1420
|
+
const failSetup = async (message, level = "warn") => {
|
|
1421
|
+
await teardownV2(disposes, rt);
|
|
1422
|
+
state.activations.delete(projectKey);
|
|
1423
|
+
// No (remaining) native claimant → routing availability signal back to
|
|
1424
|
+
// its default so redirects never point at dead tools.
|
|
1425
|
+
syncRoutingNativeToolsFlag(rt, state);
|
|
1426
|
+
emitHostLog(ctx, level, message);
|
|
1427
|
+
};
|
|
1428
|
+
try {
|
|
1429
|
+
const sessionHookSurface = typeof ctx?.session?.hook === "function";
|
|
1430
|
+
// const (not let): closures below (optional registrations, cleanup) must
|
|
1431
|
+
// see a definitely-assigned runtime — TS cannot narrow the outer `let`.
|
|
1432
|
+
const activeRt = await createPluginRuntime(ctx, projectDir, {
|
|
1433
|
+
sessionContextDegraded: !sessionHookSurface,
|
|
1434
|
+
// v2 session.hook("context") also runs for compaction — compaction
|
|
1435
|
+
// injection degrades exactly with the session-context surface. A
|
|
1436
|
+
// registration FAILURE despite the surface is corrected below via
|
|
1437
|
+
// markCapabilityDegraded.
|
|
1438
|
+
preCompactDegraded: !sessionHookSurface,
|
|
1439
|
+
});
|
|
1440
|
+
rt = activeRt;
|
|
1441
|
+
const handlers = createSharedHandlers(activeRt);
|
|
1442
|
+
// Mandatory 1/2: native ctx_* tools via ctx.tool.transform — attempted
|
|
1443
|
+
// FIRST as the most failure-prone mandatory piece. Failing here leaves
|
|
1444
|
+
// ZERO registrations on this path (no tool hooks exist yet to tear down
|
|
1445
|
+
// or leave stale).
|
|
1446
|
+
const nativeReg = await registerNativeToolsV2(ctx, activeRt);
|
|
1447
|
+
if (!nativeReg.ok) {
|
|
1448
|
+
await failSetup("context-mode v2 setup: native tool registration unavailable (no ctx.tool.transform/ToolEditor) — activation not claimed so the v1 entry can still claim; ctx_* tools remain available via the MCP fallback (mcp.context-mode)");
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (nativeReg.dispose)
|
|
1452
|
+
disposes.push(nativeReg.dispose);
|
|
1453
|
+
// Mandatory 2/2: tool execute before/after. On failure the teardown
|
|
1454
|
+
// below unregisters the native tools captured above; hooks the host
|
|
1455
|
+
// registered WITHOUT a dispose handle are neutralized by the liveness
|
|
1456
|
+
// gate (rt.closed) instead — they can never touch the closed DB.
|
|
1457
|
+
const toolRegs = await registerToolExecuteHooksV2(ctx, activeRt, handlers);
|
|
1458
|
+
if (!toolRegs.before || !toolRegs.after) {
|
|
1459
|
+
await failSetup(`context-mode v2 setup: tool execute hook registration failed (before=${toolRegs.before}, after=${toolRegs.after}) — activation not claimed; ctx_* tools remain available via the MCP fallback (mcp.context-mode)`, "error");
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
disposes.push(...toolRegs.disposes);
|
|
1463
|
+
// Full mandatory surface acquired — the claim is now real.
|
|
1464
|
+
state.activations.set(projectKey, {
|
|
1465
|
+
flavor: "v2",
|
|
1466
|
+
claimedAt: Date.now(),
|
|
1467
|
+
nativeToolsConfirmed: true,
|
|
1468
|
+
});
|
|
1469
|
+
// Native ctx_* tools are now reachable in-process → routing may emit
|
|
1470
|
+
// curl/HTTP redirect + deny guidance pointing at them even without an
|
|
1471
|
+
// MCP server (v1 never sets this: it gates on the MCP sentinel as always).
|
|
1472
|
+
syncRoutingNativeToolsFlag(activeRt, state);
|
|
1473
|
+
// Optional: session "context" hook (routing block + resume snapshot +
|
|
1474
|
+
// compaction injection — v2 runs this hook for continuations AND
|
|
1475
|
+
// compaction, covering the v1 system.transform AND compacting flows).
|
|
1476
|
+
const sessionContext = await attemptOptionalV2(activeRt, {
|
|
1477
|
+
surfacePresent: sessionHookSurface,
|
|
1478
|
+
register: () => registerSessionContextV2(ctx, activeRt, handlers),
|
|
1479
|
+
logKeyMissing: "v2-session-context-missing",
|
|
1480
|
+
missingMessage: "context-mode v2: session context injection unavailable (no ctx.session.hook('context')) — routing block + resume snapshot + compaction injection will not happen",
|
|
1481
|
+
logKeyFailed: "v2-session-context-failed",
|
|
1482
|
+
failedMessage: "context-mode v2: session context hook registration failed — routing block + resume snapshot + compaction injection will not happen",
|
|
1483
|
+
degradeCapabilities: ["sessionStart", "canInjectSessionContext", "preCompact"],
|
|
1484
|
+
});
|
|
1485
|
+
disposes.push(...sessionContext.disposes);
|
|
1486
|
+
// Optional: session "prompt" hook (user-prompt capture — v1 chat.message
|
|
1487
|
+
// equivalent).
|
|
1488
|
+
const promptCapture = await attemptOptionalV2(activeRt, {
|
|
1489
|
+
surfacePresent: sessionHookSurface,
|
|
1490
|
+
register: () => registerSessionPromptV2(ctx, activeRt, handlers),
|
|
1491
|
+
logKeyMissing: "v2-prompt-capture-missing",
|
|
1492
|
+
missingMessage: "context-mode v2: user-prompt capture unavailable (no ctx.session.hook('prompt')) — prompt capture inactive",
|
|
1493
|
+
});
|
|
1494
|
+
disposes.push(...promptCapture.disposes);
|
|
1495
|
+
// Optional: event bus (per-turn token + cost capture).
|
|
1496
|
+
const eventBus = await attemptOptionalV2(activeRt, {
|
|
1497
|
+
surfacePresent: typeof ctx?.event?.subscribe === "function",
|
|
1498
|
+
register: () => registerEventBusV2(ctx, activeRt, handlers),
|
|
1499
|
+
logKeyMissing: "v2-event-bus-missing",
|
|
1500
|
+
missingMessage: "context-mode v2: event bus unavailable (no ctx.event.subscribe) — per-turn token/cost capture inactive",
|
|
1501
|
+
});
|
|
1502
|
+
disposes.push(...eventBus.disposes);
|
|
1503
|
+
rt.logOnce("v2-setup-complete", `context-mode v2 setup complete: native tools via ctx.tool.transform; tool hooks via ${toolRegs.via}; session context: ${sessionContext.status}; prompt capture: ${promptCapture.status}; event bus: ${eventBus.status}`, "info");
|
|
1504
|
+
return async () => {
|
|
1505
|
+
// Cleanup: unregister everything registered, close the runtime DB
|
|
1506
|
+
// handle, then release the activation claim so a reload can re-claim.
|
|
1507
|
+
await teardownV2(disposes, rt);
|
|
1508
|
+
state.activations.delete(projectKey);
|
|
1509
|
+
// Torn down → routing availability signal off (unless another native
|
|
1510
|
+
// claimant remains in this process).
|
|
1511
|
+
syncRoutingNativeToolsFlag(rt, state);
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
catch (err) {
|
|
1515
|
+
// Setup failed — leave no live partial registrations and no leaked DB
|
|
1516
|
+
// handle; release the claim so a retry (or the other flavor) can activate.
|
|
1517
|
+
await teardownV2(disposes, rt);
|
|
1518
|
+
state.activations.delete(projectKey);
|
|
1519
|
+
syncRoutingNativeToolsFlag(rt, state);
|
|
1520
|
+
emitHostLog(ctx, "error", formatPluginError(null, err));
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
// ── Exports ──────────────────────────────────────────────
|
|
1525
|
+
// KiloCode PluginModule / OpenCode v2 PluginModule: default export with
|
|
1526
|
+
// { id, server, setup } shape — v1 hosts call server(input), v2 hosts call
|
|
1527
|
+
// setup(ctx). No `tui` marker (would invalidate server loading).
|
|
1528
|
+
// OpenCode compat: named exports for direct import("@mxalbert/context-mode/plugin")
|
|
1529
|
+
export default {
|
|
1530
|
+
id: "context-mode",
|
|
1531
|
+
server: createContextModePlugin,
|
|
1532
|
+
setup: setupV2,
|
|
1533
|
+
};
|
|
1534
|
+
export { createContextModePlugin as ContextModePlugin, setupV2 as ContextModeSetup };
|
|
1535
|
+
// Test surface — exported for unit testing the quorum substring fix (#487).
|
|
1536
|
+
export { systemHasRoutingInstructions, ROUTING_MARKERS };
|