cursor-opencode-provider 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +8 -5
  2. package/dist/context/agents.js +1 -1
  3. package/dist/context/build.d.ts +2 -0
  4. package/dist/context/build.js +22 -10
  5. package/dist/context/epoch.d.ts +59 -0
  6. package/dist/context/epoch.js +224 -0
  7. package/dist/context/frozen.d.ts +4 -2
  8. package/dist/context/frozen.js +31 -11
  9. package/dist/context/index.d.ts +2 -0
  10. package/dist/context/index.js +2 -0
  11. package/dist/context/overlay.d.ts +44 -0
  12. package/dist/context/overlay.js +96 -0
  13. package/dist/debug.d.ts +9 -1
  14. package/dist/debug.js +46 -5
  15. package/dist/host-event-bridge.d.ts +22 -0
  16. package/dist/host-event-bridge.js +22 -0
  17. package/dist/image-input.js +36 -11
  18. package/dist/index.d.ts +3 -0
  19. package/dist/language-model.d.ts +51 -3
  20. package/dist/language-model.js +569 -140
  21. package/dist/model-config.js +5 -1
  22. package/dist/plugin-opencode2.js +10 -4
  23. package/dist/plugin.js +26 -15
  24. package/dist/pricing-data.d.ts +40 -0
  25. package/dist/pricing-data.js +40 -0
  26. package/dist/protocol/ask-question.d.ts +8 -9
  27. package/dist/protocol/ask-question.js +8 -9
  28. package/dist/protocol/conversation-bind.js +18 -8
  29. package/dist/protocol/create-plan.d.ts +6 -7
  30. package/dist/protocol/create-plan.js +4 -5
  31. package/dist/protocol/messages.js +16 -1
  32. package/dist/protocol/struct.js +1 -1
  33. package/dist/protocol/switch-mode.d.ts +3 -4
  34. package/dist/protocol/switch-mode.js +21 -12
  35. package/dist/protocol/tool-call-bridge.d.ts +4 -1
  36. package/dist/protocol/tool-call-bridge.js +27 -5
  37. package/dist/protocol/tools.d.ts +4 -2
  38. package/dist/protocol/tools.js +247 -51
  39. package/dist/session-directory.d.ts +23 -4
  40. package/dist/session-directory.js +36 -4
  41. package/dist/session.d.ts +23 -3
  42. package/dist/session.js +68 -8
  43. package/dist/shared.d.ts +2 -0
  44. package/dist/shared.js +2 -0
  45. package/dist/transport/connect.js +78 -10
  46. package/dist/transport/https-proxy.d.ts +56 -0
  47. package/dist/transport/https-proxy.js +348 -0
  48. package/dist/usage.d.ts +11 -0
  49. package/dist/usage.js +33 -7
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -189,7 +189,7 @@ Image support is resolved in this order:
189
189
  2. The `Capabilities` column in Cursor's [`docs.md`](https://cursor.com/docs.md), generated alongside context and pricing metadata
190
190
  3. Text-only when neither source describes the model
191
191
 
192
- OpenCode image file parts are decoded from bytes, base64/data URLs, local file URLs, or HTTP(S) URLs and sent through Cursor's `UserMessage.selected_context.selected_images` field. On fresh or rebased Runs, the provider also harvests image `file-data` from tool results and `file` parts from assistant history; previously sent history images are deduplicated by content hash per OpenCode session. Held-open continuation results remain text-only because Cursor's exec-result channel has no image field. Attachments are limited to 20 MiB total, matching OpenCode's desktop attachment budget. PDF, audio, and video inputs are not advertised or silently discarded.
192
+ OpenCode image file parts are decoded from bytes, base64/data URLs, local file URLs, or HTTP(S) URLs and sent through Cursor's `UserMessage.selected_context.selected_images` field. On fresh or rebased Runs, the provider also harvests image `file-data` from tool results and `file` parts from assistant and historical user messages (the trailing user message stays owned by last-user extraction); previously sent history images are deduplicated by content hash per OpenCode session. Held-open continuation results remain text-only because Cursor's exec-result channel has no image field. Attachments are limited to 20 MiB total, matching OpenCode's desktop attachment budget. PDF, audio, and video inputs are not advertised or silently discarded.
193
193
 
194
194
  #### Max mode
195
195
 
@@ -229,6 +229,8 @@ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `crs
229
229
  | `CURSOR_WEBSITE_URL` | Override OAuth login base URL (default `https://cursor.com`) |
230
230
  | `CURSOR_API_BASE_URL` | Override API base for auth, model discovery, and `GetServerConfig` agent URL resolution (default `https://api2.cursor.sh`) |
231
231
  | `CURSOR_GET_SERVER_CONFIG_TELEMETRY` | Set to `1` or `true` to opt the `GetServerConfig` lookup into telemetry in OpenCode/plugin usage |
232
+ | `HTTPS_PROXY` / `https_proxy` | HTTP CONNECT proxy for HTTPS. Bun `fetch` uses this for unary RPCs; the Run HTTP/2 session also tunnels through it (see `NO_PROXY`). The Run tunnel supports only `http://` proxy URLs (Basic auth via userinfo); `https://` and SOCKS proxy URLs leave the Run on a direct connection. `HTTP_PROXY` is not used for HTTPS |
233
+ | `NO_PROXY` / `no_proxy` | Comma/space hosts that bypass the proxy (`*`, exact host, or `.suffix` / `suffix` domain forms; a `host:port` entry bypasses only that port) |
232
234
  | `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
233
235
  | `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default: `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) |
234
236
  | `CURSOR_OPENCODE2_DEV_ENTRY` | **Local OpenCode 2.0 only.** Absolute path to a built entry file (usually `dist/index.js`). Rewrites the AI SDK package to `aisdk:file://…` so the daemon imports your local build instead of `npm install`-ing the published package. Export it **before** `opencode2 service start`, then restart after rebuilds. Unset in production. See [OpenCode 2.0 local clone](docs/opencode-2.md#from-a-local-clone-cursor_opencode2_dev_entry). |
@@ -284,6 +286,7 @@ OpenCode
284
286
  | `src/context/paths.ts` | Host cache root + Cursor project metadata under `<host-cache>/projects/<slug>/` |
285
287
  | `src/agent-url.ts` | `GetServerConfig` fetch + in-process memo (region-specific Run host) |
286
288
  | `src/transport/connect.ts` | HTTP/2 bidi stream and unary RPC calls |
289
+ | `src/transport/https-proxy.ts` | `HTTPS_PROXY` / `NO_PROXY` CONNECT tunnel for Run sessions |
287
290
  | `src/protocol/` | Protobuf encode/decode, checksum/device ids, exec + display tool-call mapping (`tool-call-bridge.ts`) |
288
291
 
289
292
  ### Injected system guidance
@@ -345,7 +348,7 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
345
348
  | Local OpenCode 2.0 still runs the published package | Set `CURSOR_OPENCODE2_DEV_ENTRY` to an absolute `…/dist/index.js` path **before** starting the daemon, persist it with `opencode2 service set env CURSOR_OPENCODE2_DEV_ENTRY …`, rebuild (`bun run build`), then `opencode2 service restart`. Loading only `dist/plugin-opencode2.js` is not enough — without the env var, 2.0 still `npm install`s the published package into the host cache. |
346
349
  | “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. |
347
350
  | Empty or stale model list | Delete `<host-cache>/cursor-models.json` (native default `~/.cache/opencode/`) and restart OpenCode. A compatibility layer may supply a different host-cache root through the structural path bridge. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
348
- | Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, resumes transient interruptions from the latest eligible Cursor checkpoint, and falls back to a fresh-history rebase only before stateful output when no checkpoint exists. Repeated interruption is surfaced as an error instead of a false successful stop; retry the turn after checking connectivity. With debug logging enabled, look for `Run interrupted`, `resuming … checkpoint`, or `rebasing fresh Run`. Restart OpenCode after rebuilding a local `file://` install. |
351
+ | Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, resumes transient interruptions from the latest eligible Cursor checkpoint, and falls back to a fresh-history rebase only before stateful output when no checkpoint exists. Repeated interruption is surfaced as an error instead of a false successful stop; retry the turn after checking connectivity. Behind an HTTP proxy, set `HTTPS_PROXY` (and `NO_PROXY` as needed): unary `fetch` and the Run HTTP/2 CONNECT tunnel both honor them. With debug logging enabled, look for `Run interrupted`, `resuming … checkpoint`, `rebasing fresh Run`, or `h2 connect via HTTPS proxy`. Restart OpenCode after rebuilding a local `file://` install. |
349
352
  | No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and recovers once from bare EOF. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. |
350
353
  | Visible `<shell_metadata>` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. |
351
354
  | `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. Known `handling=unsupported` variants (Cursor-native capabilities without a safe OpenCode AI SDK bridge) are now soft-denied on the wire with a populated typed error/`rejected`/`allowlisted:false` result and the Run continues — look for `exec: SOFT-DENIED …` in the debug log and a `Workspace root: …` suffix in the deny text — while `unknown request field` still hard-fails as protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. |
@@ -362,8 +365,8 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
362
365
 
363
366
  - **Personal use / ToS** — this provider speaks Cursor’s private agent protocol (CLI-shaped client identity). Use only with an account you own; Cursor may change or restrict the API without notice.
364
367
  - **Cursor's checkpoint controls displayed totals** — live continuation captures show `TurnEnded` counters can decrease between turns and can represent aggregate model work rather than final context occupancy. When a checkpoint contains `ConversationStateStructure.token_details`, the provider makes AI SDK input + output equal Cursor's `used_tokens`; at `TurnEnded`, output/reasoning remain based on `TurnEnded`, while uncached/cache-read/cache-write input are proportionally normalized to the remaining context input. OpenCode TUI/GUI replace each assistant message's tokens (TUI requires `tokens.output > 0`) and add cost per step, so mid-turn tool boundaries emit the last-known occupancy snapshot rather than a delta or a billed zero. Those snapshots set `output=1` so the footer updates and `providerMetadata.copilot.totalNanoAiu: 0` so OpenCode's getUsage reports $0 instead of charging occupancy as a new prompt. A Run without fresh token details retains the previous checkpoint snapshot and marks `providerMetadata.cursor.context` as `source: "checkpoint-previous-turn", stale: true`. If no checkpoint has ever supplied token details, standard usage is zero and context metadata is absent, so `TurnEnded` is never misrepresented as occupancy; its exact values remain under `providerMetadata.cursor.*Raw`. The full known snapshot includes `usedTokens`, `maxTokens`, percentage, category breakdown, source, and staleness. Cursor consistently sends `cache_write=0` in captured Runs, so no alternate write count is available.
365
- - **`request_context` from OpenCode** — each Run sends Cursor `RequestContext` built from OpenCode project context (workspace env, `AGENTS.md` / `instructions`, `.opencode` agents/skills/plugins, git, layout, plus `.claude`/`.agents` skill fallbacks). Expensive workspace state (rules, environment, repository, git, and layout) is frozen per Cursor conversation and persisted for restart recovery. Tools/MCP descriptors, skills, subagents, and plugin metadata are rediscovered and overlaid each Run; unchanged overlays reuse byte-identical encoded context, while enabling/disabling a capability becomes visible on the next turn. Concurrent first Runs share one base build, and compaction/reset clears it. Its canonical root is also used by the [injected system guidance](#injected-system-guidance). Same discovery as OpenCode — including `.cursor/` paths only when listed in `instructions`. Cursor-only cloud/sandbox marketplace surfaces are omitted. `env.workspace_paths` / `process_working_directory` stay on the real git workspace; Cursor's metadata root (`project_folder`, MCP `workspace_project_dir`, terminals/transcripts) is advertised under `<host-cache>/projects/<slug>/` (default `~/.cache/opencode/projects/…`) so dumps like `agent-tools/` do not land in the repo. OpenCode remains the permission authority: its coarse allow/ask/deny configuration is not fabricated into Cursor's unrelated allow/block instruction-list messages.
366
- - **Configured MCP tools keep their upstream server id** — OpenCode builtins and plugin/custom tools are advertised under a synthetic `opencode` MCP server. Tools whose flattened name matches an MCP server in merged `opencode.json` configuration (`github_create_pull_request`, …) are grouped into that server's `mcp_descriptors` / `provider_identifier` (`github`, …). Unknown underscore-containing names stay under `opencode` rather than being guessed incorrectly. Cursor's MCP-state exec probe is answered from the same advertised descriptors before the actual tool request, using the full canonical tool-definition identity required by native `get_mcp_tools`; exec still reconstructs the full OpenCode tool id.
368
+ - **`request_context` from OpenCode** — each Run sends Cursor `RequestContext` built from OpenCode project context (workspace env, `AGENTS.md` / `instructions`, `.opencode` agents/skills/plugins, git, layout, plus `.claude`/`.agents` skill fallbacks). Expensive workspace state (rules, environment, repository, git, and layout) is frozen per Cursor conversation and persisted for restart recovery. Tools/MCP descriptors, skills, subagents, and plugin metadata are rediscovered each Run then epoch-held (equal ids keep frozen bytes; new ids append); unchanged overlays reuse byte-identical encoded context. Concurrent first Runs share one base build, and compaction/reset clears it. Its canonical root is also used by the [injected system guidance](#injected-system-guidance). Same discovery as OpenCode — including `.cursor/` paths only when listed in `instructions`. Cursor-only cloud/sandbox marketplace surfaces are omitted. `env.workspace_paths` / `process_working_directory` stay on the real git workspace; Cursor's metadata root (`project_folder`, MCP `workspace_project_dir`, terminals/transcripts) is advertised under `<host-cache>/projects/<slug>/` (default `~/.cache/opencode/projects/…`) so dumps like `agent-tools/` do not land in the repo. OpenCode remains the permission authority: its coarse allow/ask/deny configuration is not fabricated into Cursor's unrelated allow/block instruction-list messages.
369
+ - **Configured MCP tools keep their upstream server id** — OpenCode builtins and plugin/custom tools are advertised under a synthetic `opencode` MCP server. Tools whose flattened name matches an MCP server in merged `opencode.json` configuration (`github_create_pull_request`, …) are grouped into that server's `mcp_descriptors` / `provider_identifier` (`github`, …). Unknown underscore-containing names stay under `opencode` rather than being guessed incorrectly. Cursor's MCP-state exec probe is answered from the same advertised descriptors before the actual tool request, using the full canonical tool-definition identity required by native `get_mcp_tools` / GetDynamicTools; exec still reconstructs the full OpenCode tool id. That discovery call is Cursor-native (not an OpenCode catalog tool). Oversized catalogs spill through `write_args` into `agent-tools/<uuid>.txt`; `WriteSuccess.path` echoes the requested path so the model receives a real `filePath` instead of an empty spill.
367
370
  - **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` → `todowrite`, `read_todos_tool_call` → `todoread`, `create_plan_tool_call` → `todowrite`); the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed native subagent/Task and Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged. All 37 Cursor CLI exec request/result pairs are inventoried by field and name. Known-but-unsupported variants are soft-denied on the held Run with a populated typed error/`rejected`/`allowlisted:false` result (or `throw` when the CLI has no typed error shape) so the turn continues; unknown future request fields still fail the Run explicitly rather than receiving a guessed response that could deadlock it.
368
371
  - **Progress-only fragments can get one continuation** — if a real tool-enabled turn ends with a short progress fragment ("Checking the workspace", "Looking into it now") and no host tool call, the provider opens one follow-up Run with a synthetic user nudge so the model can call a listed tool or finish the answer. Complete answers that merely start with those verbs are not continued. Title/compaction turns (`allowTools=false`) never continue. If the continuation fails, the original `turn_ended` is still reported as a successful stop.
369
372
  - **Tool availability is per OpenCode agent** — Cursor can request native capabilities such as Task even when a child or restricted OpenCode agent did not advertise the corresponding host tool. The provider prompts Cursor with the exact current catalog and checks every decoded host-tool exec request against it. An unavailable request is answered on Cursor's correlated typed result channel and is never emitted as OpenCode's `invalid` tool.
@@ -377,7 +380,7 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
377
380
  - **Other Cursor-native interaction queries remain headless** — remaining UI/approval *queries* (as distinct from display tool calls and the AskQuestion / SwitchMode / CreatePlan / GenerateImage bridges) still cannot be surfaced through the AI SDK provider interface. The normal system prompt redirects available web capabilities to executable host tools (`custom_websearch`, `custom_webfetch`); native web/PR/MCP/SCM requests are declined so they remain behind host tool permissions. Compaction prompts are unchanged. Unknown future interaction variants fail the turn explicitly instead of hanging the Run stream.
378
381
  - **Compaction resets Cursor conversation state without resetting stable workspace context** — the classic plugin marks OpenCode's `compaction` agent explicitly. On those turns the provider mints an isolated Cursor `conversation_id`, drops the prior checkpoint + KV blobs, preserves real tool outputs as OpenCode-host observations in the seed history, and re-advertises the session's last tool catalog while refusing execution during the summary itself. The first normal turn then rebases once more onto a fresh conversation seeded with OpenCode's compacted prompt and normal system instructions, so the summary-agent checkpoint cannot suppress later tool calls. Across both id rotations, the frozen workspace/rules/environment base and its prior encoded-context comparison seed are transferred instead of rebuilt; tools, skills, agents, plugins, and MCP state are still rediscovered, and the complete context is reused only when its bytes remain identical. Ordinary no-tool / `toolChoice:none` calls do not reset conversation state. Debug logs include system-prompt and encoded RequestContext hashes for cache-prefix verification.
379
382
  - **Conversation bindings survive provider restarts** — after each successful `TurnEnded`, the OpenCode-session binding, latest checkpoint, checkpoint-reachable KV blob graph, frozen request-context base, last real tool catalog, host agent, stable system-prompt hash, and pending post-compaction rebase marker are atomically replaced in that session's private `<host-cache>/cursor-conversations/*.pb.gz` snapshot. The protobuf stores checkpoints, blob ids/data, and request context as bytes without JSON/base64 expansion, then gzip compresses the complete record. A checkpoint alone is insufficient: Cursor requests its referenced hashes through `get_blob` when the next Run starts. Reachability follows Cursor CLI's export traversal (turns, messages, steps, images, summaries, todos, prompts, and nested subagents), pruning superseded blobs; decode failure or a missing referenced hash conservatively retains the complete blob set. The stable OpenCode session id selects the file; Cursor conversation ids may rotate during compaction, agent switches, or prompt changes and remain inside the snapshot. Independent files prevent concurrent OpenCode processes from losing unrelated sessions by replacing a shared aggregate cache. Startup restores records updated within 24 hours and safely flushes older ones without deleting a concurrently published fresh replacement. Experimental legacy JSON snapshots are discarded rather than migrated. Debug logs report hydration as `restored`, `missing`, `invalid`, or `expired`, plus the compressed and protobuf snapshot sizes. The restored catalog is a fallback only for lifecycle turns such as compaction; an ordinary restricted/no-tool turn never resurrects it. Process-global bindings and prompt/catalog state still use a 256-session LRU bound; memory eviction drops the live checkpoint, blobs, and frozen context, which can be rehydrated from the restart cache on later use.
380
- - **Oversized checkpoint graphs rebase instead of flooding the Run stream** — before reusing a checkpoint, the provider measures its reachable KV graph. State above 100 MiB, or an incomplete graph with a missing/undecodable reference, rotates to a fresh Cursor conversation seeded from OpenCode's authoritative history. KV, request-context, MCP-state, interaction, and heartbeat writes are ordered per Run and await HTTP/2 drain, so a large blob reply cannot create an unbounded queue later misreported as heartbeat backpressure. Debug logs distinguish checkpoint-envelope bytes, reachable blob bytes, seed size, and encoded Run-request estimate; raw blob bytes are never presented as tokens.
383
+ - **Oversized / incomplete checkpoint graphs warn but do not remint** — before reusing a checkpoint, the provider measures its reachable KV graph. State above 100 MiB (Cursor CLI export/transfer budget) or an incomplete graph is logged (`action=warm-reuse`) and the sticky conversation is kept, matching CLI soft-reuse. KV, request-context, MCP-state, interaction, and heartbeat writes are ordered per Run and await HTTP/2 drain, so a large blob reply cannot create an unbounded queue later misreported as heartbeat backpressure. Debug logs distinguish checkpoint-envelope bytes, reachable blob bytes, seed size, and encoded Run-request estimate; raw blob bytes are never presented as tokens.
381
384
  - **Interrupted Runs resume from checkpoints** — a remote EOF, Connect end-stream, or trailer error is never emitted as a successful `stop`. When the failed Run produced an eligible checkpoint, the provider opens a new RPC for the same conversation and sends that state with `ResumeAction`, so completed text and tool work are not replayed. Before any stateful output, an interruption without a checkpoint can still rebase from OpenCode history. Stateful interruptions without a checkpoint are surfaced because replay would be ambiguous; retry exhaustion remains explicit. A transport closure after `turn_ended` is treated as successful completion.
382
385
  - **Large reads are capped by OpenCode, and the cap is reported** — OpenCode 1.x `read` stops at `MAX_BYTES = 50 * 1024` and appends `(Output capped at 50 KB. Showing lines X-Y. Use offset=N to continue.)`. OpenCode 2 prints `Read file <path>, lines <start>-<end>` with `N: ` prefixes and, when the page ends before EOF, `[Output truncated. Continue reading with offset: N]` (50 KB or 2,000 lines); it also shortens each individual line after 2,000 characters. The provider strips either envelope before returning content to Cursor so the model cannot echo wrappers into a later write, but it re-states every partial result: a native read appends a `[Partial read: …]` marker after the content, an MCP read gets a separate notice content item, and a Pi read carries structured `PiReadExecSuccess.truncation`. Relative grep, glob, and directory entries are resolved against the workspace root. Shell stdout absolutizes only relative path tokens, not prose. Checkpointed turns, which do not resend the system prompt, get that same root on the user message. The structured `truncated` flag alone is not enough — verified against live `gpt-5.4-mini` and `grok-4.5` sessions, both of which asserted "highly confident" that partial content was the whole file until the textual marker was added. Deliberately paged reads (explicit `offset`/`limit`) are not marked unless the host also hits the byte cap or character-truncates a line. Whole-file writes and overwriting `Add File` patches that echo the marker are rejected before OpenCode executes them; targeted edits remain possible, including edits to source that quotes the warning text. Cursor's private legacy-edit read is handled separately as described above, so an edit can operate on a workspace file larger than 50 KB without turning the capped preview into a replacement. The cap is hardcoded in OpenCode's read tool — it is not the configurable `tool_output.max_bytes`, which governs `Truncate.Service` and not read's line accumulation — so ordinary model reads still require paging with `offset`.
383
386
  - **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
@@ -70,7 +70,7 @@ async function scanAgentRoot(root, out) {
70
70
  let entries;
71
71
  try {
72
72
  entries = await readdir(dir, { withFileTypes: true });
73
- entries.sort((a, b) => a.name.localeCompare(b.name));
73
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
74
74
  }
75
75
  catch {
76
76
  return;
@@ -3,6 +3,8 @@ export type BuildRequestContextInput = {
3
3
  workspaceRoot: string;
4
4
  tools?: OpencodeToolDef[];
5
5
  providerIdentifier?: string;
6
+ /** When set, skills/subagents/plugins are epoch-held for this conversation. */
7
+ conversationId?: string;
6
8
  };
7
9
  export declare const DYNAMIC_REQUEST_CONTEXT_KEYS: readonly ["tools", "agent_skills", "custom_subagents", "mcp_file_system_options", "mcp_meta_tool_options", "web_search_enabled", "web_fetch_enabled", "agent_skills_info_complete", "custom_subagents_info_complete", "mcp_file_system_info_complete", "mcp_info_complete", "hooks_additional_context"];
8
10
  export type DynamicRequestContextKey = typeof DYNAMIC_REQUEST_CONTEXT_KEYS[number];
@@ -8,6 +8,7 @@ import { collectGit } from "./git.js";
8
8
  import { collectProjectLayout } from "./layout.js";
9
9
  import { buildEnv } from "./env.js";
10
10
  import { ensureOpencodeProjectDir } from "./paths.js";
11
+ import { holdCapabilityOverlay } from "./overlay.js";
11
12
  import { traceRequestContextPaths } from "../debug.js";
12
13
  export const DYNAMIC_REQUEST_CONTEXT_KEYS = [
13
14
  "tools",
@@ -123,14 +124,27 @@ async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, wor
123
124
  `Delegate to the host-configured ${agent.name} subagent; its host instructions and tools apply.`,
124
125
  };
125
126
  });
127
+ const liveSkills = skills.map((s) => ({
128
+ id: s.name,
129
+ full_path: s.fullPath,
130
+ content: s.content,
131
+ description: s.description,
132
+ }));
133
+ const livePlugins = plugins.map((p) => ({
134
+ id: p.id,
135
+ line: `opencode-plugin:${p.source}:${p.id}`,
136
+ }));
137
+ const overlay = input.conversationId
138
+ ? holdCapabilityOverlay(input.conversationId, {
139
+ skills: liveSkills,
140
+ subagents: customSubagents,
141
+ plugins: livePlugins,
142
+ })
143
+ : { skills: liveSkills.map(({ full_path, content, description }) => ({ full_path, content, description })), subagents: customSubagents, plugins: livePlugins };
126
144
  const dynamic = {
127
145
  tools: flat,
128
- agent_skills: skills.map((s) => ({
129
- full_path: s.fullPath,
130
- content: s.content,
131
- description: s.description,
132
- })),
133
- custom_subagents: customSubagents,
146
+ agent_skills: overlay.skills,
147
+ custom_subagents: overlay.subagents,
134
148
  mcp_file_system_options: {
135
149
  enabled: true,
136
150
  // Cursor metadata root (mcps / agent-tools), not the git workspace.
@@ -152,10 +166,8 @@ async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, wor
152
166
  mcp_file_system_info_complete: true,
153
167
  mcp_info_complete: true,
154
168
  };
155
- if (plugins.length > 0) {
156
- dynamic.hooks_additional_context = plugins
157
- .map((p) => `opencode-plugin:${p.source}:${p.id}`)
158
- .join("\n");
169
+ if (overlay.plugins.length > 0) {
170
+ dynamic.hooks_additional_context = overlay.plugins.map((p) => p.line).join("\n");
159
171
  }
160
172
  return dynamic;
161
173
  }
@@ -0,0 +1,59 @@
1
+ export declare const MAX_CONTEXT_EPOCHS = 256;
2
+ export type ContextSourceSnapshot = {
3
+ hostSystemHash: string;
4
+ guidanceHash: string;
5
+ hostAgent: string;
6
+ workspaceRoot: string;
7
+ };
8
+ export type ContextEpoch = {
9
+ conversationId: string;
10
+ /** Exact system text seeded at epoch start. Empty when recovered from checkpoint. */
11
+ baselineSystemPrompt: string;
12
+ baselineHash: string;
13
+ /** True when restart hydrated past a checkpoint without the original baseline bytes. */
14
+ recovered: boolean;
15
+ snapshot: ContextSourceSnapshot;
16
+ };
17
+ export type AdmitContextEpochInput = {
18
+ conversationId: string;
19
+ /** True when this Run carries conversation_state (no systemPrompt on the wire). */
20
+ hasCheckpoint: boolean;
21
+ hostSystem?: string;
22
+ guidance?: string;
23
+ hostAgent?: string;
24
+ workspaceRoot: string;
25
+ /**
26
+ * One-shot reminders already wrapped (mode / kickoff). Always chronological —
27
+ * never folded into the frozen baseline.
28
+ */
29
+ oneShotReminders?: readonly string[];
30
+ };
31
+ export type AdmitContextEpochResult = {
32
+ action: "initialize" | "unchanged" | "updated" | "recovered";
33
+ /** Wire systemPrompt for seed Runs only — always the frozen baseline once set. */
34
+ seedSystemPrompt?: string;
35
+ /** Combined Mid-Conversation System Message (append after user text). */
36
+ midConversationMessage?: string;
37
+ epoch: ContextEpoch;
38
+ };
39
+ /**
40
+ * Admit System Context at a Safe Provider-Turn Boundary.
41
+ *
42
+ * - First seed: freeze baseline, return it as seedSystemPrompt; one-shots → mid.
43
+ * - Checkpoint turn: never returns seedSystemPrompt; source diffs + one-shots → mid.
44
+ * - Reseed same epoch: return frozen baseline bytes (not live host text).
45
+ * - Recovered (restart past a checkpoint, original bytes unknown): never freeze
46
+ * live host text as a new baseline and never send a seed systemPrompt.
47
+ */
48
+ export declare function admitContextEpoch(input: AdmitContextEpochInput): AdmitContextEpochResult;
49
+ export declare function getContextEpoch(conversationId: string): ContextEpoch | undefined;
50
+ /** Drop epoch state (compaction remint / binding clear). */
51
+ export declare function clearContextEpoch(conversationId: string): void;
52
+ /**
53
+ * Compaction remints conversation_id. RequestContext workspace base transfers;
54
+ * System Context baseline does not — the destination starts a fresh epoch.
55
+ */
56
+ export declare function endContextEpoch(previousConversationId: string, nextConversationId?: string): void;
57
+ export declare function resetContextEpochsForTests(): void;
58
+ /** Append a mid-conversation message after user text (V2: user precedes update). */
59
+ export declare function appendMidConversationMessage(userText: string, midConversationMessage: string | undefined): string;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Context Epoch — V2-style immutable system baseline + chronological updates.
3
+ *
4
+ * Mirrors OpenCode research CONTEXT.md:
5
+ * - One Baseline System Context per conversation_id (provider-cache prefix)
6
+ * - Later Context Source changes → Mid-Conversation System Message on the
7
+ * user turn (Cursor checkpoint Runs cannot rewrite systemPrompt)
8
+ * - Epoch ends on conversation remint (compaction / post-compaction rebase)
9
+ *
10
+ * Cursor constraint: systemPrompt is only sent on seed Runs. Checkpointed
11
+ * Runs admit updates exclusively via the live user message.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { trace } from "../debug.js";
15
+ export const MAX_CONTEXT_EPOCHS = 256;
16
+ const byConversationId = new Map();
17
+ function sha(text) {
18
+ return createHash("sha256").update(text).digest("hex");
19
+ }
20
+ function joinSections(parts) {
21
+ return parts.map((part) => part?.trim()).filter((part) => !!part).join("\n\n");
22
+ }
23
+ function snapshotOf(input) {
24
+ return {
25
+ hostSystemHash: sha(input.hostSystem?.trim() || ""),
26
+ guidanceHash: sha(input.guidance?.trim() || ""),
27
+ hostAgent: input.hostAgent?.trim() || "",
28
+ workspaceRoot: input.workspaceRoot,
29
+ };
30
+ }
31
+ function buildBaseline(input) {
32
+ // Durable System Context only. Mode/kickoff one-shots stay chronological.
33
+ return joinSections([input.hostSystem, input.guidance]);
34
+ }
35
+ function wrapReminder(body) {
36
+ const trimmed = body.trim();
37
+ if (!trimmed)
38
+ return "";
39
+ if (trimmed.startsWith("<system_reminder>"))
40
+ return trimmed;
41
+ return `<system_reminder>\n${trimmed}\n</system_reminder>`;
42
+ }
43
+ function renderSourceUpdates(previous, next, live) {
44
+ const parts = [];
45
+ if (next.hostAgent && previous.hostAgent !== next.hostAgent) {
46
+ parts.push(wrapReminder(`Host primary agent is now ${JSON.stringify(next.hostAgent)}. ` +
47
+ "Follow this agent's instructions and tool permissions for the rest of the turn. " +
48
+ "This is a chronological context update; the original system baseline is unchanged."));
49
+ }
50
+ if (live.hostSystem?.trim() && previous.hostSystemHash !== next.hostSystemHash) {
51
+ parts.push(wrapReminder("Host system instructions were updated for this session. Effective instructions:\n\n" +
52
+ live.hostSystem.trim()));
53
+ }
54
+ if (live.guidance?.trim() && previous.guidanceHash !== next.guidanceHash) {
55
+ parts.push(wrapReminder("OpenCode interaction guidance was updated for this session:\n\n" + live.guidance.trim()));
56
+ }
57
+ if (previous.workspaceRoot !== next.workspaceRoot && next.workspaceRoot) {
58
+ parts.push(wrapReminder(`Workspace root is now ${JSON.stringify(next.workspaceRoot)}. ` +
59
+ "Resolve workspace paths against exactly this root; never invent an absolute prefix."));
60
+ }
61
+ return parts.filter(Boolean);
62
+ }
63
+ function touch(epoch) {
64
+ byConversationId.delete(epoch.conversationId);
65
+ byConversationId.set(epoch.conversationId, epoch);
66
+ while (byConversationId.size > MAX_CONTEXT_EPOCHS) {
67
+ const oldest = byConversationId.keys().next().value;
68
+ if (!oldest)
69
+ break;
70
+ byConversationId.delete(oldest);
71
+ }
72
+ }
73
+ function combineMessages(parts) {
74
+ const text = parts.map((part) => part.trim()).filter(Boolean).join("\n\n");
75
+ return text || undefined;
76
+ }
77
+ /**
78
+ * Admit System Context at a Safe Provider-Turn Boundary.
79
+ *
80
+ * - First seed: freeze baseline, return it as seedSystemPrompt; one-shots → mid.
81
+ * - Checkpoint turn: never returns seedSystemPrompt; source diffs + one-shots → mid.
82
+ * - Reseed same epoch: return frozen baseline bytes (not live host text).
83
+ * - Recovered (restart past a checkpoint, original bytes unknown): never freeze
84
+ * live host text as a new baseline and never send a seed systemPrompt.
85
+ */
86
+ export function admitContextEpoch(input) {
87
+ const conversationId = input.conversationId;
88
+ const nextSnap = snapshotOf(input);
89
+ const oneShots = (input.oneShotReminders ?? []).map((part) => part.trim()).filter(Boolean);
90
+ const existing = byConversationId.get(conversationId);
91
+ if (!existing) {
92
+ if (input.hasCheckpoint) {
93
+ // Restart / soft-evict recovery: Cursor already holds the baseline in the
94
+ // checkpoint. Record the live snapshot without reseeding systemPrompt.
95
+ const epoch = {
96
+ conversationId,
97
+ baselineSystemPrompt: "",
98
+ baselineHash: "",
99
+ recovered: true,
100
+ snapshot: nextSnap,
101
+ };
102
+ touch(epoch);
103
+ const midConversationMessage = combineMessages(oneShots);
104
+ trace(`context epoch: recovered conversationId=${conversationId} ` +
105
+ `oneShots=${oneShots.length}`);
106
+ return { action: "recovered", midConversationMessage, epoch };
107
+ }
108
+ const baselineSystemPrompt = buildBaseline(input);
109
+ const baselineHash = sha(baselineSystemPrompt);
110
+ const epoch = {
111
+ conversationId,
112
+ baselineSystemPrompt,
113
+ baselineHash,
114
+ recovered: false,
115
+ snapshot: nextSnap,
116
+ };
117
+ touch(epoch);
118
+ trace(`context epoch: initialize conversationId=${conversationId} ` +
119
+ `baselineHash=${baselineHash.slice(0, 16)} baselineLen=${baselineSystemPrompt.length} ` +
120
+ `oneShots=${oneShots.length}`);
121
+ return {
122
+ action: "initialize",
123
+ seedSystemPrompt: baselineSystemPrompt || undefined,
124
+ midConversationMessage: combineMessages(oneShots),
125
+ epoch,
126
+ };
127
+ }
128
+ // Optional sources may be unavailable on a turn. Absence is not an explicit
129
+ // deletion signal: carrying the prior observation avoids synthetic context
130
+ // churn and preserves the warm prefix.
131
+ const observedSnap = {
132
+ hostSystemHash: input.hostSystem?.trim()
133
+ ? nextSnap.hostSystemHash
134
+ : existing.snapshot.hostSystemHash,
135
+ guidanceHash: input.guidance?.trim()
136
+ ? nextSnap.guidanceHash
137
+ : existing.snapshot.guidanceHash,
138
+ hostAgent: input.hostAgent?.trim()
139
+ ? nextSnap.hostAgent
140
+ : existing.snapshot.hostAgent,
141
+ workspaceRoot: nextSnap.workspaceRoot || existing.snapshot.workspaceRoot,
142
+ };
143
+ const updates = renderSourceUpdates(existing.snapshot, observedSnap, input);
144
+ const changed = existing.snapshot.hostSystemHash !== observedSnap.hostSystemHash
145
+ || existing.snapshot.guidanceHash !== observedSnap.guidanceHash
146
+ || existing.snapshot.hostAgent !== observedSnap.hostAgent
147
+ || existing.snapshot.workspaceRoot !== observedSnap.workspaceRoot;
148
+ if (changed) {
149
+ existing.snapshot = observedSnap;
150
+ }
151
+ touch(existing);
152
+ if (input.hasCheckpoint) {
153
+ const midConversationMessage = combineMessages([...updates, ...oneShots]);
154
+ if (changed || oneShots.length) {
155
+ trace(`context epoch: ${changed ? "updated" : "unchanged"} conversationId=${conversationId} ` +
156
+ `checkpoint=1 updates=${updates.length} oneShots=${oneShots.length}`);
157
+ }
158
+ return {
159
+ action: changed ? "updated" : "unchanged",
160
+ midConversationMessage,
161
+ epoch: existing,
162
+ };
163
+ }
164
+ // Recovered epochs have no original baseline bytes. A later seed Run must
165
+ // not freeze live host text as if it were the prefix Cursor already holds.
166
+ if (existing.recovered && !existing.baselineSystemPrompt) {
167
+ const midConversationMessage = combineMessages([...updates, ...oneShots]);
168
+ trace(`context epoch: recovered-hold conversationId=${conversationId} ` +
169
+ `checkpoint=0 updates=${updates.length} oneShots=${oneShots.length} seed=omitted`);
170
+ return {
171
+ action: changed ? "updated" : "recovered",
172
+ midConversationMessage,
173
+ epoch: existing,
174
+ };
175
+ }
176
+ const midConversationMessage = combineMessages([...updates, ...oneShots]);
177
+ if (changed) {
178
+ trace(`context epoch: updated conversationId=${conversationId} checkpoint=0 ` +
179
+ `updates=${updates.length} seed=frozen-baseline`);
180
+ }
181
+ return {
182
+ action: changed ? "updated" : "unchanged",
183
+ seedSystemPrompt: existing.baselineSystemPrompt || undefined,
184
+ midConversationMessage,
185
+ epoch: existing,
186
+ };
187
+ }
188
+ export function getContextEpoch(conversationId) {
189
+ const epoch = byConversationId.get(conversationId);
190
+ if (!epoch)
191
+ return undefined;
192
+ touch(epoch);
193
+ return epoch;
194
+ }
195
+ /** Drop epoch state (compaction remint / binding clear). */
196
+ export function clearContextEpoch(conversationId) {
197
+ if (!conversationId)
198
+ return;
199
+ if (byConversationId.delete(conversationId)) {
200
+ trace(`context epoch: cleared conversationId=${conversationId}`);
201
+ }
202
+ }
203
+ /**
204
+ * Compaction remints conversation_id. RequestContext workspace base transfers;
205
+ * System Context baseline does not — the destination starts a fresh epoch.
206
+ */
207
+ export function endContextEpoch(previousConversationId, nextConversationId) {
208
+ clearContextEpoch(previousConversationId);
209
+ if (nextConversationId)
210
+ clearContextEpoch(nextConversationId);
211
+ }
212
+ export function resetContextEpochsForTests() {
213
+ byConversationId.clear();
214
+ }
215
+ /** Append a mid-conversation message after user text (V2: user precedes update). */
216
+ export function appendMidConversationMessage(userText, midConversationMessage) {
217
+ if (!midConversationMessage?.trim())
218
+ return userText;
219
+ if (!userText.trim())
220
+ return midConversationMessage.trim();
221
+ if (userText.includes(midConversationMessage.trim()))
222
+ return userText;
223
+ return `${userText}\n\n${midConversationMessage.trim()}`;
224
+ }
@@ -12,14 +12,16 @@ export declare function clearFrozenRequestContext(conversationId: string): void;
12
12
  * Compaction changes Cursor's state/checkpoint identity, not the OpenCode
13
13
  * workspace. Preserve the expensive base and the prior materialized bytes as a
14
14
  * comparison seed; getOrBuildRequestContext still rediscovers live capability
15
- * overlays and only reuses the complete context when those bytes also match.
15
+ * overlays (then epoch-holds them) and only reuses the complete context when
16
+ * those bytes also match.
16
17
  */
17
18
  export declare function transferFrozenRequestContext(previousConversationId: string, nextConversationId: string): boolean;
18
19
  /** Test helper — wipe all frozen contexts. */
19
20
  export declare function resetFrozenRequestContextsForTests(): void;
20
21
  /**
21
22
  * Return a stable-base + live-overlay RequestContext for `conversationId`.
22
- * The base is built once; capability sections are rediscovered every Run.
23
+ * The base is built once; capability sections are rediscovered every Run
24
+ * and then epoch-held.
23
25
  */
24
26
  export declare function getOrBuildRequestContext(conversationId: string, input: BuildRequestContextInput, opts?: {
25
27
  refresh?: boolean;
@@ -1,4 +1,6 @@
1
1
  import { buildDynamicRequestContext, buildRequestContext, materializeRequestContext, requestContextBase, } from "./build.js";
2
+ import { clearContextEpoch, endContextEpoch, resetContextEpochsForTests } from "./epoch.js";
3
+ import { clearOverlayHold, resetOverlayHoldsForTests, transferOverlayHold, } from "./overlay.js";
2
4
  import { trace } from "../debug.js";
3
5
  import { encodeMessage } from "../protocol/messages.js";
4
6
  /**
@@ -8,10 +10,10 @@ import { encodeMessage } from "../protocol/messages.js";
8
10
  * base plus live plugin/tool overlays. Rebuilding volatile git/layout data on
9
11
  * every Run shifts the prompt prefix and tanks prompt-cache hits.
10
12
  *
11
- * Skills, subagents, plugin metadata, and tool/MCP capabilities are rebuilt on
12
- * each Run and overlaid on that base. If their encoded bytes did not change,
13
- * the exact prior materialized object is reused. This matches Cursor's newer
14
- * baked-context + live-overlay design without retaining stale capabilities.
13
+ * Skills, subagents, plugin metadata, and tool/MCP capabilities are rediscovered
14
+ * each Run, then epoch-held (equal ids keep frozen bytes; new ids append) and
15
+ * overlaid on that base. If the encoded overlay bytes did not change, the exact
16
+ * prior materialized object is reused.
15
17
  */
16
18
  const byConversationId = new Map();
17
19
  const materializedByConversationId = new Map();
@@ -41,6 +43,7 @@ function remember(conversationId, context) {
41
43
  break;
42
44
  byConversationId.delete(oldest);
43
45
  materializedByConversationId.delete(oldest);
46
+ clearOverlayHold(oldest);
44
47
  }
45
48
  }
46
49
  /** Frozen stable RequestContext base for this conversation, if any. */
@@ -63,6 +66,8 @@ export function setFrozenRequestContext(conversationId, context) {
63
66
  export function clearFrozenRequestContext(conversationId) {
64
67
  byConversationId.delete(conversationId);
65
68
  materializedByConversationId.delete(conversationId);
69
+ clearOverlayHold(conversationId);
70
+ clearContextEpoch(conversationId);
66
71
  }
67
72
  /**
68
73
  * Move a stable workspace base across a conversation-id reset.
@@ -70,15 +75,24 @@ export function clearFrozenRequestContext(conversationId) {
70
75
  * Compaction changes Cursor's state/checkpoint identity, not the OpenCode
71
76
  * workspace. Preserve the expensive base and the prior materialized bytes as a
72
77
  * comparison seed; getOrBuildRequestContext still rediscovers live capability
73
- * overlays and only reuses the complete context when those bytes also match.
78
+ * overlays (then epoch-holds them) and only reuses the complete context when
79
+ * those bytes also match.
74
80
  */
75
81
  export function transferFrozenRequestContext(previousConversationId, nextConversationId) {
76
82
  if (!previousConversationId || !nextConversationId)
77
83
  return false;
78
84
  const base = byConversationId.get(previousConversationId);
79
85
  const materialized = materializedByConversationId.get(previousConversationId);
80
- clearFrozenRequestContext(previousConversationId);
81
- clearFrozenRequestContext(nextConversationId);
86
+ // System Context epoch does not transfer — compaction starts a fresh baseline.
87
+ // Overlay hold does transfer: same workspace, same advertised skill/agent/plugin
88
+ // bytes, so the comparison seed can still match. clearFrozenRequestContext
89
+ // also drops epoch state for each id.
90
+ endContextEpoch(previousConversationId, nextConversationId);
91
+ transferOverlayHold(previousConversationId, nextConversationId);
92
+ byConversationId.delete(previousConversationId);
93
+ materializedByConversationId.delete(previousConversationId);
94
+ byConversationId.delete(nextConversationId);
95
+ materializedByConversationId.delete(nextConversationId);
82
96
  if (!base)
83
97
  return false;
84
98
  remember(nextConversationId, base);
@@ -95,6 +109,8 @@ export function resetFrozenRequestContextsForTests() {
95
109
  byConversationId.clear();
96
110
  materializedByConversationId.clear();
97
111
  buildsByConversationId.clear();
112
+ resetOverlayHoldsForTests();
113
+ resetContextEpochsForTests();
98
114
  }
99
115
  function sameBytes(a, b) {
100
116
  if (a.length !== b.length)
@@ -119,13 +135,17 @@ function rememberMaterialized(conversationId, context) {
119
135
  }
120
136
  /**
121
137
  * Return a stable-base + live-overlay RequestContext for `conversationId`.
122
- * The base is built once; capability sections are rediscovered every Run.
138
+ * The base is built once; capability sections are rediscovered every Run
139
+ * and then epoch-held.
123
140
  */
124
141
  export async function getOrBuildRequestContext(conversationId, input, opts) {
142
+ const scoped = conversationId ? { ...input, conversationId } : input;
143
+ if (opts?.refresh && conversationId)
144
+ clearOverlayHold(conversationId);
125
145
  if (!opts?.refresh && conversationId) {
126
146
  const base = getFrozenRequestContext(conversationId);
127
147
  if (base) {
128
- const dynamic = await buildDynamicRequestContext(input);
148
+ const dynamic = await buildDynamicRequestContext(scoped);
129
149
  const materialized = rememberMaterialized(conversationId, materializeRequestContext(base, dynamic));
130
150
  trace(`request_context: materialized conversationId=${conversationId} ` +
131
151
  `tools=${Array.isArray(materialized.context.tools) ? materialized.context.tools.length : 0} ` +
@@ -140,10 +160,10 @@ export async function getOrBuildRequestContext(conversationId, input, opts) {
140
160
  const inFlight = buildsByConversationId.get(conversationId);
141
161
  if (inFlight) {
142
162
  await inFlight;
143
- return getOrBuildRequestContext(conversationId, input);
163
+ return getOrBuildRequestContext(conversationId, scoped);
144
164
  }
145
165
  }
146
- const build = buildRequestContext(input);
166
+ const build = buildRequestContext(scoped);
147
167
  if (conversationId)
148
168
  buildsByConversationId.set(conversationId, build);
149
169
  let context;
@@ -1,3 +1,5 @@
1
1
  export { buildDynamicRequestContext, buildRequestContext, DYNAMIC_REQUEST_CONTEXT_KEYS, materializeRequestContext, requestContextBase, type BuildRequestContextInput, } from "./build.js";
2
2
  export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
3
+ export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
4
+ export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, type AdmitContextEpochInput, type AdmitContextEpochResult, type ContextEpoch, type ContextSourceSnapshot, } from "./epoch.js";
3
5
  export { HOST_PATH_BRIDGE, getHostCacheDirOverride, opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir, hostGlobalDataDir, resolveHostCacheDir, setHostCacheDirOverride, } from "./paths.js";
@@ -1,3 +1,5 @@
1
1
  export { buildDynamicRequestContext, buildRequestContext, DYNAMIC_REQUEST_CONTEXT_KEYS, materializeRequestContext, requestContextBase, } from "./build.js";
2
2
  export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
3
+ export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
4
+ export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, } from "./epoch.js";
3
5
  export { HOST_PATH_BRIDGE, getHostCacheDirOverride, opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir, hostGlobalDataDir, resolveHostCacheDir, setHostCacheDirOverride, } from "./paths.js";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Epoch-hold live RequestContext overlay lists (skills, subagents, plugins).
3
+ *
4
+ * Same policy as the tool catalog: first nonempty freeze is UTF-16 by id,
5
+ * equal ids keep frozen bytes (including content), new ids append at the
6
+ * tail, shrink keeps the epoch advertisement. Discovery still runs every
7
+ * Run; this only canonicalizes what is advertised.
8
+ */
9
+ export declare const MAX_OVERLAY_HOLDS = 256;
10
+ export type OverlaySkill = {
11
+ id: string;
12
+ full_path: string;
13
+ content: string;
14
+ description: string;
15
+ };
16
+ export type OverlaySubagent = {
17
+ full_path: string;
18
+ name: string;
19
+ description: string;
20
+ prompt: string;
21
+ };
22
+ export type OverlayPlugin = {
23
+ id: string;
24
+ line: string;
25
+ };
26
+ export type OverlayHold = {
27
+ skills: OverlaySkill[];
28
+ subagents: OverlaySubagent[];
29
+ plugins: OverlayPlugin[];
30
+ };
31
+ export type OverlayWire = {
32
+ skills: Array<{
33
+ full_path: string;
34
+ content: string;
35
+ description: string;
36
+ }>;
37
+ subagents: OverlaySubagent[];
38
+ plugins: OverlayPlugin[];
39
+ };
40
+ /** Merge live discovery into the conversation overlay epoch. */
41
+ export declare function holdCapabilityOverlay(conversationId: string, live: OverlayHold): OverlayWire;
42
+ export declare function clearOverlayHold(conversationId: string): void;
43
+ export declare function transferOverlayHold(previousConversationId: string, nextConversationId: string): void;
44
+ export declare function resetOverlayHoldsForTests(): void;