Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
49f20b6
feat(opencode): add experimental code mode execute tool
rekram1-node Jun 29, 2026
b0aa6bf
feat(opencode): namespace code mode tools by MCP server
rekram1-node Jun 29, 2026
cad83ab
feat(opencode): progressive tool discovery for code mode
rekram1-node Jun 29, 2026
caa5f28
refactor(opencode): simplify code mode namespace listing
rekram1-node Jun 29, 2026
3a6621c
chore(opencode): vendor rune interpreter for code mode
rekram1-node Jun 30, 2026
14527d2
feat(opencode): code mode result+attachments envelope and typed describe
rekram1-node Jun 30, 2026
71ffc73
feat(opencode): run code mode on the vendored rune interpreter
rekram1-node Jun 30, 2026
1448f24
feat(opencode): budgeted tool preview in code mode description
rekram1-node Jun 30, 2026
acc1742
test(opencode): end-to-end code mode test over a real MCP server
rekram1-node Jun 30, 2026
ba12049
feat(opencode): tokenized, ranked tool search in code mode
rekram1-node Jun 30, 2026
a3bfba8
feat(opencode): unify code mode discovery under tools.$rune
rekram1-node Jun 30, 2026
cbcc67b
refactor(opencode): single source of discovery docs, clearer attachme…
rekram1-node Jun 30, 2026
79275e6
fix(opencode): describe attachments as routable values, not opaque
rekram1-node Jun 30, 2026
394c370
docs(opencode): add rune.md (how it works, what's missing)
rekram1-node Jun 30, 2026
55aa8cc
feat(opencode): inline budgeted call signatures in code mode preview
rekram1-node Jun 30, 2026
dc75ea0
feat(opencode): state whether the code mode tool list is complete or …
rekram1-node Jun 30, 2026
bc427b1
feat: live code-mode execute UI in the TUI
rekram1-node Jun 30, 2026
2726a74
fix(tui): normalize execute tool styling
rekram1-node Jun 30, 2026
05b9346
fix(tui): surface execute child call details
rekram1-node Jul 1, 2026
5085a13
feat(opencode): render code-mode types as TypeScript, not JSON Schema
rekram1-node Jul 1, 2026
c16bba8
feat(opencode): JSDoc tags, Result<T> return hints, and opaque attach…
rekram1-node Jul 1, 2026
2d9015c
fix(tui): simplify execute running state
rekram1-node Jul 1, 2026
064c34b
feat(opencode): capture console output and surface it to the model
rekram1-node Jul 1, 2026
cc437b9
fix(opencode): make Rune tolerate idiomatic defensive JavaScript
rekram1-node Jul 1, 2026
e06a099
fix(opencode): let NaN/Infinity flow in Rune, normalize to null at th…
rekram1-node Jul 1, 2026
51ea0ac
feat(codemode): add @opencode-ai/codemode confined execution package
rekram1-node Jul 2, 2026
90b4af6
feat(opencode): rebuild code mode as an MCP adapter over @opencode-ai…
rekram1-node Jul 2, 2026
2a13900
feat(codemode): simplify limits, enrich search, condense instructions
rekram1-node Jul 2, 2026
cafcec4
feat(opencode): run code mode without execution limits
rekram1-node Jul 2, 2026
560fc7f
feat(codemode): expand JS parity and make output truncation opt-in
rekram1-node Jul 3, 2026
27fbf7c
feat(opencode): rely on native tool-output truncation for code mode
rekram1-node Jul 3, 2026
231144f
docs(codemode): fix stale claims, log wiring-review findings
rekram1-node Jul 3, 2026
9a6fdc4
fix(opencode): interrupt code mode execution on cancel
rekram1-node Jul 3, 2026
9be249a
refactor(opencode): align code mode module with session conventions
rekram1-node Jul 3, 2026
8f330de
feat(codemode): render bracket notation for non-identifier tool names
rekram1-node Jul 3, 2026
9431ba3
refactor(opencode): promote code mode to a registry tool service
rekram1-node Jul 3, 2026
3b3c6d6
Merge remote-tracking branch 'origin/dev' into codemode-v2
rekram1-node Jul 3, 2026
fedae57
fix(codemode): quote non-identifier keys in signatures; unify compoun…
rekram1-node Jul 3, 2026
1b7f565
refactor(opencode): share the MCP invocation middle between direct an…
rekram1-node Jul 3, 2026
8ca700f
fix(opencode): isolate the code-mode integration suite from bun modul…
rekram1-node Jul 3, 2026
dca428c
fix(ci): pin node-gyp so native install scripts stop resolving node-g…
rekram1-node Jul 3, 2026
16f3c8e
fix(codemode): align code-mode catalog filtering and schema union ren…
rekram1-node Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(opencode): unify code mode discovery under tools.$rune
Remove Rune's vestigial built-in $rune.search/$rune.describe (which only
saw effect-Schema Definitions, never our dynamic MCP tools) and the
reserved-namespace guard. Move our ranked search and typed describe under
tools.$rune.* — the runtime's own namespace, separate from MCP server
namespaces and collision-proof since $ never appears in a sanitized
server name. One discovery implementation, no dead code.
  • Loading branch information
rekram1-node committed Jun 30, 2026
commit a3bfba809ff81b590ec474b39b657563d5d459d4
35 changes: 21 additions & 14 deletions packages/opencode/src/session/code-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const CODE_LIMITS: ExecutionLimits = {

export const Parameters = Schema.Struct({
code: Schema.String.annotate({
description: "JavaScript to run. Discover tools with `tools.search`/`tools.describe`, call them, and `return` the final value.",
description: "JavaScript to run. Discover tools with `tools.$rune.search`/`tools.$rune.describe`, call them, and `return` the final value.",
}),
})

Expand All @@ -42,6 +42,10 @@ export type Envelope = { result: unknown; attachments?: Attachment[] }
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
const SEARCH = "search"
const DESCRIBE = "describe"
// The runtime's own capabilities live under `tools.$rune.*`, separated from the
// MCP server namespaces. `$` can never appear in a sanitized server name, so this
// namespace is collision-proof.
const RUNE_NS = "$rune"

type CatalogEntry = {
path: string
Expand Down Expand Up @@ -164,15 +168,16 @@ const PREVIEW_BUDGET = 2000
/**
* The execute tool description: the calling convention, the discovery API, and the
* list of namespaces. A budgeted preview of individual tools is inlined; the full
* per-tool signatures are still fetched on demand with `tools.describe`.
* per-tool signatures are still fetched on demand with `tools.$rune.describe`.
*/
export function describe(groups: Map<string, CatalogEntry[]>): string {
const lines = [
"Execute JavaScript with access to connected MCP tools, grouped into namespaces (one per MCP server).",
"",
"Discover tools inside your program, then call them:",
"- `await tools.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`",
"- `await tools.describe(path)` -> `{ path, description, signature, inputSchema, outputSchema? }`",
"The runtime provides two discovery capabilities under `tools.$rune` (its own namespace, separate",
"from your MCP servers):",
"- `await tools.$rune.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`",
"- `await tools.$rune.describe(path)` -> `{ path, description, signature, inputSchema, outputSchema? }`",
"- Call a tool by its path: `await tools.<server>.<tool>(input)`. Each resolves to `{ result, attachments? }`.",
"",
"Every tool call and your final `return` use the same envelope: `{ result, attachments? }`.",
Expand All @@ -181,13 +186,13 @@ export function describe(groups: Map<string, CatalogEntry[]>): string {
"contents of an attachment in code — only pass it along.",
"",
"Compose multiple calls in one program and `return` the final value — intermediate results stay in the",
"sandbox and never re-enter the conversation. Use `tools.search('', { namespace })` to list a namespace.",
"sandbox and never re-enter the conversation. Use `tools.$rune.search('', { namespace })` to list a namespace.",
]
if (groups.size === 0) {
lines.push("", "No MCP servers are currently connected.")
return lines.join("\n")
}
lines.push("", "Available namespaces (use tools.search / tools.describe to explore tools not shown):")
lines.push("", "Available namespaces (use tools.$rune.search / tools.$rune.describe to explore tools not shown):")
let used = 0
let previewing = true
for (const [server, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
Expand Down Expand Up @@ -456,11 +461,14 @@ export function define(
})

// The Rune host-tool tree: per-server namespaces (`tools.<server>.<tool>`)
// plus the top-level discovery helpers. The interpreter resolves and invokes
// these; approving `execute` does not approve any child call.
// plus the runtime's own discovery capabilities under `tools.$rune.*`. The
// interpreter resolves and invokes these; approving `execute` does not
// approve any child call.
const tools: HostTools = {
[SEARCH]: (query: unknown, options: unknown) => Effect.succeed(search(query, options)),
[DESCRIBE]: (path: unknown) => Effect.succeed(describeTool(path)),
[RUNE_NS]: {
[SEARCH]: (query: unknown, options: unknown) => Effect.succeed(search(query, options)),
[DESCRIBE]: (path: unknown) => Effect.succeed(describeTool(path)),
},
}
for (const entry of catalog) {
if (!entry.tool.execute) continue
Expand All @@ -487,11 +495,10 @@ export function define(
...(attachments && attachments.length > 0 ? { attachments } : {}),
} satisfies Tool.ExecuteResult<Metadata>
}
// Rune's built-in unknown-capability hint points at `$rune.search`; redirect
// the model to this integration's actual discovery entrypoint instead.
// Point the model at discovery when it references a tool that does not exist.
const hint =
result.error.kind === "UnknownCapability"
? "\nUse tools.search(query) to discover available tools."
? "\nUse tools.$rune.search(query) to discover available tools."
: ""
return {
title: "Code mode",
Expand Down
45 changes: 3 additions & 42 deletions packages/opencode/src/session/rune/tool-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ export type ToolDescription = {

export type SafeObject = Record<string, unknown>

const reservedNamespace = "$rune"

export class ToolReference {
constructor(readonly path: ReadonlyArray<string>) {}
}
Expand Down Expand Up @@ -149,11 +147,9 @@ const visibleDefinitions = <R>(tools: HostTools<R>) =>
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
visibleDefinitions(tools).map(({ description }) => description)

export const assertValidTools = <R>(tools: HostTools<R>): void => {
if (Object.hasOwn(tools, reservedNamespace)) {
throw new Error(`Tool namespace '${reservedNamespace}' is reserved for Rune discovery capabilities.`)
}
}
// Discovery is provided by the embedder as ordinary host tools (e.g. under a
// `$rune` namespace), not by the runtime, so there are no reserved namespaces.
export const assertValidTools = <R>(_tools: HostTools<R>): void => {}

export const instructions = <R>(tools: HostTools<R>): string => {
const described = catalog(tools)
Expand Down Expand Up @@ -214,7 +210,6 @@ export const make = <R>(
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
let auditBytes = 0
const visibleCatalog = visibleDefinitions(tools)

const checkedCopyIn = (value: unknown, label: string): unknown => {
const copied = copyIn(value, label, dataLimits)
Expand Down Expand Up @@ -248,40 +243,6 @@ export const make = <R>(
throw new ToolRuntimeError("InvalidDataValue", `Arguments for tool '${name}' exceed ${dataLimits.maxDataBytes} bytes.`)
}
const call = { name }
if (name === "$rune.search") {
const input = externalArgs[0]
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
throw new ToolRuntimeError("InvalidToolInput", "tools.$rune.search expects { query?: string; limit?: number }.")
}
const request = input as { query?: unknown; limit?: unknown }
if (request.query !== undefined && typeof request.query !== "string") {
throw new ToolRuntimeError("InvalidToolInput", "tools.$rune.search query must be a string when provided.")
}
if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isFinite(request.limit) || request.limit <= 0)) {
throw new ToolRuntimeError("InvalidToolInput", "tools.$rune.search limit must be a positive number when provided.")
}
const query = typeof request.query === "string" ? request.query.toLowerCase() : ""
recordCall(call)
const matched = visibleCatalog
.filter((item) => `${item.path} ${item.definition.description}`.toLowerCase().includes(query))
.map((item) => ({ path: item.path, description: item.definition.description }))
const limit = typeof request.limit === "number" ? Math.floor(request.limit) : 12
return checkedCopyIn({ items: matched.slice(0, limit), total: matched.length }, "Result from tool '$rune.search'")
}
if (name === "$rune.describe") {
const input = externalArgs[0]
const requested = input !== null && typeof input === "object" && !Array.isArray(input)
? (input as { path?: unknown }).path
: undefined
if (externalArgs.length !== 1 || typeof requested !== "string") {
throw new ToolRuntimeError("InvalidToolInput", "tools.$rune.describe expects { path: string }.")
}
recordCall(call)
const found = visibleCatalog.find((item) => item.path === requested)
if (!found) throw new ToolRuntimeError("UnknownCapability", `Unknown tool '${String(requested)}'.`)
return checkedCopyIn(found.description, "Result from tool '$rune.describe'")
}

const tool = resolve(tools, path)
let describedInput: unknown
if (isDefinition(tool)) {
Expand Down
10 changes: 5 additions & 5 deletions packages/opencode/test/session/code-mode-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ beforeAll(async () => {

describe("code mode integration (real MCP server)", () => {
test("describe exposes the typed return signature from the tool's outputSchema", async () => {
const out = await run("return await tools.describe('fixtures.add')")
const out = await run("return await tools.$rune.describe('fixtures.add')")
const desc = JSON.parse(out.output)
expect(desc.path).toBe("fixtures.add")
expect(desc.signature).toBe(
Expand All @@ -124,13 +124,13 @@ describe("code mode integration (real MCP server)", () => {
})

test("describe falls back to result: unknown when no outputSchema is declared", async () => {
const out = await run("return await tools.describe('fixtures.get_text')")
const out = await run("return await tools.$rune.describe('fixtures.get_text')")
const desc = JSON.parse(out.output)
expect(desc.signature).toContain("Promise<{ result: unknown; attachments?: Attachment[] }>")
})

test("search finds a tool by keyword", async () => {
const out = await run("return await tools.search('screenshot')")
const out = await run("return await tools.$rune.search('screenshot')")
const result = JSON.parse(out.output)
expect(result.items.map((i: any) => i.path)).toContain("fixtures.screenshot")
})
Expand Down Expand Up @@ -196,8 +196,8 @@ describe("code mode integration (real MCP server)", () => {
tool.execute(
{
code: `
await tools.search('add')
await tools.describe('fixtures.add')
await tools.$rune.search('add')
await tools.$rune.describe('fixtures.add')
await tools.fixtures.add({ a: 1, b: 1 })
return 'done'
`,
Expand Down
20 changes: 10 additions & 10 deletions packages/opencode/test/session/code-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@ describe("code mode execute", () => {
)
const description = describeTools(groups)

expect(description).toContain("tools.search(query")
expect(description).toContain("tools.describe(path)")
expect(description).toContain("tools.$rune.search(query")
expect(description).toContain("tools.$rune.describe(path)")
expect(description).toContain("- github (2 tools)")
expect(description).toContain("- linear (1 tool)")
// Small catalog: individual tools are previewed inline as `<server>.<tool>`.
expect(description).toContain("github.create_issue")
expect(description).toContain("linear.search")
// ...but never full signatures (those come from tools.describe).
// ...but never full signatures (those come from tools.$rune.describe).
expect(description).not.toContain("): Promise<")
})

Expand All @@ -107,7 +107,7 @@ describe("code mode execute", () => {
expect(description).toContain("alpha.op_0")
})

test("tools.search and tools.describe expose the catalog on demand", async () => {
test("tools.$rune.search and tools.$rune.describe expose the catalog on demand", async () => {
const tool = await build({
github_create_issue: mcpTool("create_issue", () => "", {
type: "object",
Expand All @@ -119,22 +119,22 @@ describe("code mode execute", () => {
})

const searched = await Effect.runPromise(
tool.execute({ code: "return await tools.search('issue', { namespace: 'github' })" }, ctx),
tool.execute({ code: "return await tools.$rune.search('issue', { namespace: 'github' })" }, ctx),
)
const search = JSON.parse(searched.output)
expect(search.total).toBe(2)
expect(search.items.map((i: any) => i.path).sort()).toEqual(["github.create_issue", "github.list_issues"])

const described = await Effect.runPromise(
tool.execute({ code: "return await tools.describe('github.create_issue')" }, ctx),
tool.execute({ code: "return await tools.$rune.describe('github.create_issue')" }, ctx),
)
const desc = JSON.parse(described.output)
expect(desc.path).toBe("github.create_issue")
expect(desc.signature).toBe(
"tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>",
)

const missing = await Effect.runPromise(tool.execute({ code: "return await tools.describe('github.nope')" }, ctx))
const missing = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('github.nope')" }, ctx))
expect(JSON.parse(missing.output).error.code).toBe("tool_not_found")
})

Expand Down Expand Up @@ -223,7 +223,7 @@ describe("code mode execute", () => {
const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
expect(output.metadata.error).toBe(true)
expect(output.output).toContain("Unknown tool 'known.missing'")
expect(output.output).toContain("tools.search")
expect(output.output).toContain("tools.$rune.search")
})

test("propagates an MCP tool error into the program", async () => {
Expand Down Expand Up @@ -306,7 +306,7 @@ describe("code mode execute", () => {
} as any,
}
const tool = await build(tools, defs)
const described = await Effect.runPromise(tool.execute({ code: "return await tools.describe('weather.current')" }, ctx))
const described = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('weather.current')" }, ctx))
const desc = JSON.parse(described.output)
expect(desc.signature).toBe(
"tools.weather.current(input: { city?: string }): Promise<{ result: { tempC: number; summary?: string }; attachments?: Attachment[] }>",
Expand Down Expand Up @@ -342,7 +342,7 @@ describe("code mode execute", () => {
}),
other_noop: mcpTool("noop", () => ""),
})
const out = await Effect.runPromise(tool.execute({ code: "return await tools.search('trace_id')" }, ctx))
const out = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.search('trace_id')" }, ctx))
const result = JSON.parse(out.output)
expect(result.items.map((i: any) => i.path)).toEqual(["traces.lookup"])
})
Expand Down