Skip to content

Resolve CLAUDE.md includes for OpenCode provider#2165

Closed
CopyPasteFail wants to merge 1 commit into
nanocoai:providersfrom
CopyPasteFail:fix/opencode-instructions-resolution
Closed

Resolve CLAUDE.md includes for OpenCode provider#2165
CopyPasteFail wants to merge 1 commit into
nanocoai:providersfrom
CopyPasteFail:fix/opencode-instructions-resolution

Conversation

@CopyPasteFail
Copy link
Copy Markdown

Summary

Fixes OpenCode provider prompt construction so CLAUDE.md include references are resolved before being injected into the prompt.

Previously, the provider read /workspace/agent/CLAUDE.md as plain text. When that file contained Claude-style include lines like:

@./.claude-shared.md
@./.claude-fragments/module-self-mod.md

those lines were sent literally to OpenCode instead of the referenced file contents.

This change adds local include resolution for CLAUDE.md before prompt wrapping.

Behavior

  • Resolves whole-line local relative includes such as @./file.md
  • Resolves nested includes relative to the included file
  • Leaves missing includes literal instead of silently deleting them
  • Blocks includes that escape the instruction root
  • Prevents infinite recursion on cyclic includes

Validation

  • git diff --check: pass
  • pnpm exec eslint src/opencode-provider-includes.test.ts: pass
  • pnpm typecheck: pass
  • pnpm exec vitest run src/opencode-provider-includes.test.ts: pass
  • pnpm test: pass, 24 files / 203 tests

Note: pnpm lint currently fails on unrelated pre-existing files in src/channels/cli.ts, src/container-runner.ts, and src/session-manager.ts. This PR does not touch those files.

@CopyPasteFail
Copy link
Copy Markdown
Author

What was the problem?

NanoClaw’s OpenCode provider was supposed to give OpenCode the same agent instructions that Claude gets.

Those instructions are stored in CLAUDE.md, but that file is not always a fully expanded instruction file. In this case, it can contain include lines like:

@./.claude-shared.md
@./.claude-fragments/module-agents.md
@./.claude-fragments/module-core.md
@./.claude-fragments/module-self-mod.md

Those lines mean: “load the contents of these other files here.”

The bug was that the OpenCode provider didn’t load those files. It just copied the literal @./... lines into the prompt.

So OpenCode was getting something like:

@./.claude-shared.md
@./.claude-fragments/module-self-mod.md

instead of the real instructions inside those files.

That means OpenCode could miss important behavioral instructions, capabilities, safety rules, or module-specific guidance.

Why did it happen?

Because the OpenCode provider treated CLAUDE.md as plain text.

The old flow was basically:

read /workspace/agent/CLAUDE.md
put that text into <system>...</system>
send it to OpenCode

That works only if CLAUDE.md already contains the final instruction text.

But in this branch, CLAUDE.md can be a composed wrapper that references other files. Claude tooling understands @./file.md style includes. OpenCode does not automatically expand those when NanoClaw manually injects the file content into the prompt.

So the mismatch was:

CLAUDE.md format assumes include expansion
OpenCode provider did no include expansion

That’s the root cause.

What was the fix?

The PR adds a small include resolver before injecting CLAUDE.md into the OpenCode prompt.

Now the flow is:

read /workspace/agent/CLAUDE.md
expand safe local @./... includes
then inject the expanded result into <system>...</system>
send it to OpenCode

The important part is that this is done inside NanoClaw before OpenCode sees the prompt.

So instead of OpenCode receiving:

@./.claude-shared.md

it receives the actual content of .claude-shared.md.

What changed in the code?

The PR changes three files:

container/agent-runner/src/providers/opencode.ts
container/agent-runner/src/providers/claude-md.ts
src/opencode-provider-includes.test.ts

1. opencode.ts

This file now imports a new helper:

import { resolveClaudeMdIncludes } from './claude-md.js';

Instead of directly reading CLAUDE.md and returning its raw text, it now calls a helper that expands local include references first.

The old logic was essentially one string called content.

The new logic uses chunks:

group CLAUDE.md content
optional global CLAUDE.md content
join them with ---

That preserves the old behavior of combining group and global instructions, but fixes what each chunk contains.

2. claude-md.ts

This is the new resolver.

It does the narrow thing needed here:

Find whole-line @... include references
Resolve local relative paths
Read those files
Insert their contents
Handle nested includes
Avoid unsafe paths
Avoid infinite recursion

It does not try to become a full Markdown parser. That’s good. The bug is narrow, so the fix should be narrow.

3. opencode-provider-includes.test.ts

This is the regression test.

It proves:

@./.claude-shared.md gets expanded
nested includes get expanded relative to the included file
missing includes stay literal
path traversal outside the instruction root is blocked
cyclic includes do not recurse forever

That test is the main thing that prevents this bug from coming back.

Why are these changes justified?

The justification is strong because the repro was direct and the fix maps exactly to the failure.

1. The bug was proven, not guessed

The repro showed:

Contains literal @ include lines: true
Contains real shared content: false
Contains real fragment content: false
BUG REPRODUCED

That proves the OpenCode prompt contained references instead of real instruction content.

2. The fix is placed at the right layer

This should not be fixed by changing OpenCode, and it should not require every instruction file to be manually flattened.

NanoClaw is the thing manually reading CLAUDE.md and injecting it into OpenCode’s prompt. So NanoClaw is responsible for turning that file into final prompt text.

The resolver is therefore correctly placed between:

read CLAUDE.md

and:

wrap prompt with <system>...</system>

3. The fix is conservative

It does not blindly include any file. It only expands local relative include paths like:

@./file.md
@../file.md

and even then, it checks that the resolved path stays inside the instruction root. That matters because without this guard, an instruction file could theoretically reference something outside the intended instruction directory.

It also leaves missing includes literal. That is safer than silently deleting them, because a missing include remains visible in the prompt/debug output rather than disappearing.

4. It handles real-world structure

The tests cover nested includes because a fragment can include another fragment. That’s realistic.

It also handles cycles because two files can accidentally include each other. Without cycle handling, a bad instruction setup could cause infinite recursion or crash.

5. It preserves existing behavior

The old behavior of group instructions plus optional global instructions is still preserved.

Before:

group CLAUDE.md
---
global CLAUDE.md

After:

expanded group CLAUDE.md
---
expanded global CLAUDE.md

So the behavior changed only where it was broken: include references now become actual content.

What is the evidence that the fix works?

The focused regression test passed:

src/opencode-provider-includes.test.ts
5 tests passed

The full root test suite passed:

Test Files 24 passed
Tests 203 passed

The final validation also passed:

diff check: 0
touched-file lint: 0
typecheck: 0
targeted regression: 0
full tests: 0
READY TO COMMIT

Repo-wide lint still fails, but only because of unrelated pre-existing issues in other files. The touched file lint passed.

Rationale

“Why not just let OpenCode handle @ includes?”

Because NanoClaw is not giving OpenCode a path to CLAUDE.md and asking it to interpret that file. NanoClaw is reading the file itself and injecting the resulting text into the prompt. Once NanoClaw does that, OpenCode just sees plain prompt text. It has no reason to go resolve local include syntax from inside that string.

“Why add a new helper file?”

Because include resolution is separate logic from provider orchestration.

opencode.ts already handles server startup, config, prompt wrapping, and provider behavior. Keeping file include expansion in claude-md.ts makes it testable and avoids making opencode.ts more tangled.

“Why not expand every @... anywhere in the file?”

Because that would be riskier. The PR only expands whole-line includes. That matches the intended include-file pattern and avoids accidentally rewriting normal prose, mentions, email-like text, or code examples containing @.

“Why leave missing includes literal?”

Because silently deleting them would hide configuration mistakes. Leaving them literal is safer and more debuggable.

“Why block paths outside the root?”

Because instruction includes should not be able to pull arbitrary files into the model prompt. Without that guard, a malicious or mistaken include could leak files outside the instruction directory.

“Why are tests in root src/ instead of container src/?”

Because root Vitest sees src/**/*.test.ts, while container/agent-runner/tsconfig.json excludes src/**/*.test.ts and is a Bun-based package. The container runner has its own Bun setup, including @types/bun, which root pnpm install does not install. That’s why a standalone container typecheck failed without Bun dependencies, while root typecheck and tests passed.

Bottom line

The PR fixes a real prompt-construction bug:

OpenCode was receiving include references instead of actual instructions.

The fix is:

Resolve safe local CLAUDE.md includes before injecting instructions into the OpenCode prompt.

The change is justified because it is narrow, tested, preserves existing behavior, blocks unsafe paths, handles nested and cyclic includes, and directly addresses the reproduced failure.

@CopyPasteFail
Copy link
Copy Markdown
Author

I tested #2153 locally against the same issue.

#2153 fixes the problem at a better layer by removing raw CLAUDE.md prompt injection from the OpenCode provider and using OpenCode’s native instructions config with concrete instruction files/globs.

This PR is still a valid alternative approach, but it duplicates the goal. I’m happy to close this in favor of #2153 if that’s the preferred direction.

@gavrielc
Copy link
Copy Markdown
Collaborator

gavrielc commented May 1, 2026

@CopyPasteFail Thanks for the PR. Closing in favor of #2153 as suggested

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants