Skip to content

feat(agents): experimental OpenCodeHarness and agents/models/opencode, with an example - #2513

Merged
mattzcarey merged 8 commits into
mainfrom
feat/opencode-harness
Oct 7, 2026
Merged

mattzcarey merged 8 commits into
mainfrom
feat/opencode-harness

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Adds an experimental OpenCodeHarness that runs OpenCode v2 (@opencode/sdk/workerd) in a Durable Object behind PiHarness's interface, a Workers AI provider for OpenCode, and an example. Try it: https://opencode-harness-example.mattzcarey.workers.dev

import { OpenCodeWorkerd } from "@opencode/sdk/workerd";
import { OpenCodeHarness } from "agents/harness/opencode";
import { createAI } from "agents/models/opencode";

export class MyAgent extends DurableObject<Env> {
  readonly ai = createAI({ binding: this.env.AI });
  readonly harness = new OpenCodeHarness({
    opencode: ({ storage }) =>
      OpenCodeWorkerd.create({ storage, plugins: [this.ai.plugin] }),
    defaults: { model: this.ai("@cf/moonshotai/kimi-k2.7-code") }
  });
  readonly lifecycle = Lifecycle.install(this).use(this.harness);
}

const { text } = await this.harness.prompt("hello");
const receipt = await this.harness.session(id).submit("…", { operationId, whenBusy: "steer" });

agents/harness/opencode

  • Same surface as PiHarness: prompt / submit / wait / abort / messages / pending, sessions.create/fork/get/list, session.steer/setModel/busy/events. It adds setAgent, history() (the whole transcript, since OpenCode compacts on its own) and log() (durable events, resumable by sequence number).
  • Operations are idempotent by id: operationId maps to OpenCode's user message id msg_<operationId>, which OpenCode deduplicates.
  • No runtime dependency on OpenCode: the user's factory imports it, so the entry point is about 40 KB.
sequenceDiagram
  participant C as caller
  participant H as OpenCodeHarness
  participant J as Lifecycle job (per session)
  participant O as OpenCode
  C->>H: submit(input, { operationId })
  H->>J: push wake (before OpenCode has the work)
  H->>O: sessions.prompt({ id: msg_<operationId> })
  J->>O: sessions.wait() inside the alarm, heartbeat
  Note over O: eviction or crash mid-turn
  J-->>H: alarm restarts the object
  H->>O: OpenCodeWorkerd.create() resumes the turn
  J->>O: wait again until idle
Loading
  • Wake. One Lifecycle job per session, singleflight with recovery loop, as in PiHarness. A step waits on sessions.wait() as alarm work and completes when the session is idle with an empty inbox. If the object died between OpenCode admitting an input and starting it, the step starts it again.
  • Busy. busy() and sessions.list() also count queued input and a turn a restart cut off. OpenCode resumes that turn in the background on boot, so a client reconnecting before then still sees the session as busy.
  • Idle close. An open OpenCode keeps periodic timers (model catalog refresh, Ollama/LM Studio/vLLM probes, cleanups) that stop the object hibernating. Every call holds a lease, and a close job shuts OpenCode down 30s after the last one, once no session is running.

Table prefix (src/harness/table-prefix.ts)

OpenCode creates unprefixed tables (session_v2, event, kv, …) and refuses to start if the database already has tables it doesn't own, such as cf_agents_*. prefixTables(storage, "opencode_") rewrites the engine's SQL by position (FROM / JOIN / INTO / TABLE / INDEX / REFERENCES / RENAME TO / qualifiers), so a column named like a table is left alone. It also shows the engine a sqlite_master with only its own objects. Nothing in it is OpenCode-specific. Upstream issue for a real option: anomalyco/opencode#53577.

agents/models/opencode

createAI({ binding }) returns model references (ai("@cf/…") → { providerID: "cloudflare", id }) and ai.plugin. The plugin registers a provider whose language hook returns agents/models/ai-sdk's model through a V4→V3 adapter (language-v3.ts). Nothing is patched globally.

  • OpenCode only loads AI SDK packages it bundles; anything else goes to an npm installer that runs before external plugins and can't work in workerd.
  • So the provider names @ai-sdk/vercel, which OpenCode bundles, as its package. That gets OpenCode past the sdk step, and the language hook then replaces the model.

Example: examples/next/harnesses/opencode

  • React client over WebSockets: transcript snapshots, live text and reasoning deltas merged per OpenCode ordinal (so a second tab joining mid-answer keeps streaming), tool cards, steer and stop, and a sessions sidebar with OpenCode's generated titles.
  • sockets.ts runs one session.events() watch per running session and stops it at idle, so the object can hibernate with sockets open.
  • plugin.ts removes OpenCode's file and shell tools, which have nothing to run on in Workers, and adds notes stored in the object's KV.
  • Both APIs are experimental; the example README is their only documentation for now.

Notes

  • OpenCode adds about 3.7 MB (gzip) to a Worker, so it needs Workers Paid.
  • If a restart cuts off a turn, OpenCode keeps only committed text and resumes with a "continue" note. The model sometimes restarts or wraps up early.
  • Closing a socket from a WebSockets onConnect handler doesn't reach the client, because it runs before the upgrade completes. The example works around it; worth a separate fix.
  • Knowing a browser's object id is enough to use its chats, as in the pi example; the README says a real app should route to an object the user is authorized for.
  • Overlaps Harness opencode #2483.

Devin Review

OpenCodeHarness hosts OpenCode v2 (@opencode/sdk/workerd) in a Durable
Object with PiHarness's interface and lifecycle: a factory opens it, one
Lifecycle wake job per session brings runs back after eviction, and
operations are idempotent by id.

- prefixTables (harness/table-prefix.ts) keeps an embedded engine's tables
  under a prefix in the object's SQLite; OpenCode's go under opencode_
  until it has its own option (anomalyco/opencode#53577).
- The harness closes OpenCode when idle: its background timers otherwise
  keep the object in memory.
- agents/models/opencode serves Workers AI through the AI binding as an
  OpenCode plugin, reusing agents/models/ai-sdk's models via a V4->V3
  adapter, with no global fetch patch.
… experimental

- examples/next/harnesses/opencode: OpenCode on a Durable Object with
  OpenCodeHarness and agents/models/opencode, a React client over
  WebSockets, and a plugin that swaps OpenCode's local tools for notes.
- session.history() returns the whole transcript; OpenCode compacts on
  its own, so messages() alone loses what the user saw.
- busy() and sessions.list() count queued input and a turn a restart cut
  off, so a client that reconnects before OpenCode's background resume
  starts still follows the run.
- OpenCodeSessionInfo carries OpenCode's generated title.
- Both entry points are experimental; the user-facing docs page is removed
  until the API settles.
@changeset-bot

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5665f5f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 8 potential issues.

Devin Review

Comment thread packages/agents/src/harness/opencode/harness.ts
return <ToolCard key={key} part={part} />;
}
})}
{live?.text && !hasText ? <Markdown text={live.text} /> : null}

@devin-ai-integration devin-ai-integration Bot Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Live answer stalls after snapshot

When a snapshot catches an unfinished part, prune discards its live prefix. Later deltas start from empty, so partTexts hides them until their length exceeds the saved text.

Learn more

Snapshots carry saved text for each assistant part, while session.text.delta and session.reasoning.delta carry only new fragments. Once this filter removes an ordinal, the next fragment starts a fresh live string in the delta handler. partTexts selects by length, so the saved text hides new fragments until their length exceeds the snapshot; if it does, it displays the suffix without the saved prefix. This affects both text and reasoning while a message remains unfinished.

Example: A snapshot contains hello for text ordinal 0 and prunes its matching live text. The next delta is world; the UI continues showing hello instead of hello world. A longer delta would instead replace the visible prefix with only the new suffix.

Recommended fix: Keep a per-ordinal accumulated string until the assistant message completes, or store a snapshot baseline and append later deltas to it. Ensure prune and partTexts compare cumulative text rather than standalone fragments after pruning.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread packages/agents/src/harness/table-prefix.ts
Comment thread packages/agents/src/models/opencode/index.ts
Comment thread examples/next/harnesses/opencode/src/use-opencode-session.ts
Comment thread examples/next/harnesses/opencode/src/client.tsx
async fetch(request: Request, env: Env): Promise<Response> {
try {
return (
(await routeAgentRequest(request, env, { cors: true })) ??

@devin-ai-integration devin-ai-integration Bot Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Object identifier grants access to another browser's chats

When someone obtains another browser's object UUID, routeAgentRequest forwards their request without checking ownership. They can read that browser's sessions and notes, submit prompts, and abort runs.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +201 to +207
if (typeof message.text !== "string" || message.text.trim() === "") {
throw new Error("Nothing to send");
}
this.#watch(session);
const receipt = await handle.submit(message.text, {
whenBusy: message.whenBusy === "steer" ? "steer" : "followUp"
});

@devin-ai-integration devin-ai-integration Bot Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Public prompts have no usage limits

Anyone reaching the example can submit arbitrarily long prompts through #dispatch without quotas or length checks. Repeated requests consume the owner's Workers AI allowance and Durable Object resources.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@agent-think

agent-think Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

🟢 agents import sizes: 2 entry points changed, no growth

Entry point Exports Largest gzip change Size now
🆕 agents/harness/opencode 4 new — 5.8 KiB
🆕 agents/models/opencode 2 new — 123.7 KiB
Changed exports (6)
Import Gzip change Size now
🆕 agents/models/opencode#createAI — 123.7 KiB
🆕 agents/models/opencode#CLOUDFLARE_PROVIDER_ID — 104.2 KiB
🆕 agents/harness/opencode#OpenCodeHarness — 5.8 KiB
🆕 agents/harness/opencode#OpenCodeSession — 1.2 KiB
🆕 agents/harness/opencode#OpenCodeSessions — 925 B
🆕 agents/harness/opencode#ROOT_SESSION — 326 B
How this works

Each runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement.

Compared 1203bdd1 → 5665f5f6 · workflow run · reported by agent-think[bot]

@pkg-pr-new

pkg-pr-new Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2513

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2513

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2513

hono-agents

npm i https://pkg.pr.new/hono-agents@2513

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2513

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2513

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2513

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2513

commit: 5665f5f

Harness and helpers:
- An idle session whose inbox starts with a move no longer re-wakes in a
  loop; only inputs, synthetic messages and compactions are re-rung.
- prefixTables rewrites PRAGMA table_info('name') with a string argument.
- agents/models/opencode reloads the provider when a model's limit changes.

Example:
- Live text is merged per OpenCode ordinal with the saved transcript, so a
  client that joins mid-answer keeps streaming.
- create() rejects when the socket closes or after 30s.
- Bad or unknown session ids get an unknown_session error, and the socket
  takes no commands; the client falls back to the root session.
- Snapshots carry the whole history, and queued work counts as busy.
- Low reasoning effort, reasoning shown when it is the whole answer, the
  info card, OpenCode's warnings in the Worker log, and a note on access.
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

… and quoted or schema-qualified PRAGMA names
# Conflicts:
#	examples/next/README.md
#	pnpm-lock.yaml
The multi-session crash test waits for three gated runs before crashing,
long enough under CI load for the 1s heartbeat alarm to fire on its own,
so runDurableObjectAlarm() can find nothing to run. The six gate runs
that follow are what prove the recovery.
@mattzcarey
mattzcarey merged commit f2f745b into main Oct 7, 2026
16 checks passed
@mattzcarey
mattzcarey deleted the feat/opencode-harness branch October 7, 2026 06:42
@github-actions github-actions Bot mentioned this pull request Oct 7, 2026
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.

1 participant