Skip to content

(opencode-workspaces-plugin): add Daytona workspace adaptor plugin for OpenCode - #38

Draft
jamesmurdza wants to merge 18 commits into
daytona:mainfrom
jamesmurdza:feat/opencode-workspaces-plugin
Draft

(opencode-workspaces-plugin): add Daytona workspace adaptor plugin for OpenCode#38
jamesmurdza wants to merge 18 commits into
daytona:mainfrom
jamesmurdza:feat/opencode-workspaces-plugin

Conversation

@jamesmurdza

@jamesmurdza jamesmurdza commented Aug 2, 2026

Copy link
Copy Markdown

Description

Adds apps/opencode-workspaces-plugin: an OpenCode plugin that provisions Daytona sandboxes as remote workspaces via OpenCode's experimental workspace-adaptor API.

Ported from daytonaio/daytona#4504, with Nx build config replaced by the standalone npm layout used elsewhere in this repo. Marked private — not published. Does not change packages/opencode-plugin.

Dependencies (app-only): opencode-ai, @opencode-ai/sdk, @types/node, typescript as devDependencies. Runtime deps unchanged from upstream.

Upstream bugs in OpenCode

When OpenCode proxies a session request to a remote workspace, it forwards the host's directory (its cwd) as a query param. That path (e.g. /tmp/… on Linux, F:\proj on Windows) doesn't exist in the sandbox, so the remote resolves it against its own cwd, the session is created against a non-existent dir, and the first prompt crashes in realPath with ENOENT. The remote server stays healthy but never produces a reply — surfacing as an opaque 500 / "Failed to send prompt".

Related upstream PRs:

Until this is fixed, this PR uses the following workaround: create() symlinks the host worktree path to the sandbox repo path so the forwarded directory resolves to the extracted repo.

Related issue(s)

Scope

  • This PR touches no more than one package/app (release routing is by changed file path).
  • The PR title follows Conventional Commits
    (e.g. feat(pi-extension): ..., fix(adk-plugin): ...) — it becomes the squash-commit
    message that release-please reads.

Checks

  • I ran the affected package's lint/build/test locally and they pass. typecheck, build, plugin.test.ts, integration.test.ts pass. e2e-tui.test.ts passes with the workaround mentioned above.
  • I updated documentation where relevant.

Legal

  • Every commit is signed off for the
    DCO (git commit -s); the sign-off matches the
    commit author.
    1 of 15 commits signed off — needs git rebase --signoff main.
  • I agree to the
    Contributor License Agreement.
    On my first PR, the CLA assistant will comment and I will reply to sign (once).

jamesmurdza and others added 15 commits August 2, 2026 15:03
…io/daytona@106ca0a3

Seeds apps/opencode-workspaces-plugin with libs/opencode-plugin as it stood
in daytonaio/daytona at 106ca0a33 (tree 1e19ef2), so that the workspace-
adaptor rewrite commits that follow apply as their authors wrote them.

This tree is the pre-rewrite plugin; the next commit replaces most of it.
It exists only to give the replayed history a faithful base.
Replaces the previous custom tools/session-manager implementation with a
workspace adaptor that runs opencode inside a Daytona sandbox. Adds a
system-prompt instructions file scoped to Daytona sessions, generates
unique opencode-prefixed sandbox names, and forwards env vars through
workspace create.

Follow-up improvements folded in:
- Drop workspace-API workarounds, import upstream types
- Clean up Daytona sandbox when create() fails partway
- Exclude host .opencode/ from sandbox repo upload
- Clean up repo tarball on sandbox after extraction
- Derive preview URL template from getPreviewLink
- Warn/error clearly when DAYTONA_API_KEY is missing
- Add integration and adapter-registration tests; fix flakiness
- Update README (troubleshooting, migration guide, instructions,
  workspace creation steps, OpenCode 1.14.x requirement, test docs)
- Add bun.lock

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: James Murdza <james@jamesmurdza.com>
macOS bsdtar embeds Apple extended attributes (e.g. com.apple.provenance)
as LIBARCHIVE.xattr.* pax headers and AppleDouble ._* entries when building
the repo upload tarball. On extraction the sandbox's GNU tar floods stdout
with "Ignoring unknown extended header keyword" warnings and litters the
repo with ._* files.

Pass --no-xattrs and COPYFILE_DISABLE=1 to suppress both. Both are no-ops on
Linux hosts (COPYFILE_DISABLE is macOS-only; --no-xattrs is GNU tar's
default), so the fix stays portable. Adds an optional env passthrough to
spawnAsync to set COPYFILE_DISABLE for the tar invocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Running against a local OpenCode build" subsection explaining how to
test the plugin against a from-source OpenCode checkout: run `bun dev` from
the OpenCode repo root with the test project as a trailing argument. Notes
the JSX-config (jsxImportSource) reason it must run from packages/opencode,
and the "Cannot find module 'react/jsx-dev-runtime'" failure that results
from launching the entry point inside the test project instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…teps

The source-parse and adaptor-registration snippets assumed different cwds
without saying so. Add the cd lines (libs/opencode-plugin and /tmp/myproject)
so each is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create() run() helper wrote every sandbox command's stdout to the host
terminal, flooding it with the opencode installer's progress bar/banner, tar
output, etc. during workspace creation. Surface command output only on
failure (already captured in the thrown error) so the happy path stays quiet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The installer's "latest release" lookup hits api.github.com, which is
rate-limited (HTTP 429) and fails with "Failed to fetch version information"
when many sandboxes install in a short window — aborting workspace creation.
Pass VERSION to skip that lookup. Also moves the env vars onto bash (right of
the pipe) where the installer actually reads them, instead of curl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
File logger to /tmp/daytona-plugin.log tracing each create()/remove()/target()
step, so a stalled or failing step in the experimental-workspace flow is
visible without streaming to the UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sandbox stops servicing new commands while opencode runs its one-time DB
migration on first start, so a health poll issued during that window hangs
indefinitely and never returns — wedging the poll loop, and thus workspace
creation, forever (the TUI shows "Creating…" with no progress) even after the
server is actually healthy.

Wrap each poll (and the final log fetch) in a 5s client-side timeout: a stuck
poll is abandoned, treated as not-ready-yet, and the loop continues. Once the
migration finishes a later poll returns and creation completes normally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drives the real OpenCode TUI via tmux (open -> /warp -> select Daytona ->
wait for sandbox -> send a message) against the released opencode binary.

Hard-asserts the plugin's responsibilities: the workspace provisions (sandbox
started, remote /global/health healthy, create() completes without hanging)
and the remote answers (session contains an assistant reply echoing a magic
token). Logs — but does not assert — whether the host TUI renders the reply,
since that depends on OpenCode's experimental-workspace global sync (an
intermittent upstream gap, not this plugin).

Gated on DAYTONA_API_KEY + tmux + a released opencode binary; skipped otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…them as dead

Idle Daytona sandboxes auto-stop after ~15 min. The workspace global-sync
connects to the sandbox via target() (it dials <url>/global/event), so once a
sandbox paused, that endpoint failed and the host logged a relentless
"failed to connect to global sync" (HTTP 400) storm — which destabilized the
host and broke chat rendering. The sandbox wasn't dead, just paused.

- target(): if the sandbox isn't 'started', resume it (sandbox.start()) and
  relaunch opencode serve (the server process doesn't survive a stop/start),
  then return the preview link. Cache entries carry a short verification TTL so
  bursty target() calls stay fast.
- create(): disable auto-delete (autoDeleteInterval: -1) so a timeout only
  pauses the sandbox (resumable) rather than deleting it (which orphans the
  OpenCode workspace entry and is what actually triggered the 400 storm).
- Factor the server launch + health-wait into ensureServerRunning(), shared by
  create() and target().

Verified live: create -> stop (simulated timeout) -> access resumes the sandbox,
relaunches the server, and /global/health is reachable through the proxy again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…alone npm setup

The plugin arrived from the Daytona monorepo carrying that repo's Nx wiring.
None of it works here, so swap it for the same standalone layout the other
Node projects in this repo use:

- drop project.json (Nx build/test/set-version/publish targets) and
  tsconfig.lib.json (project references into ../../tsconfig.base.json, which
  does not exist here); fold the compiler options into a single tsconfig.json
- drop bun.lock for package-lock.json — CI installs with `npm ci`
- drop README.legacy.md and .npmignore, both artifacts of npm publishing
- mark the package private: this is an app, not a published package
- add scripts: build (tsc, emits in place), typecheck, test (bun)
- add LICENSE, and ignore the in-place tsc output

Also add `opencode-ai` as a devDependency. Every test file needs an `opencode`
binary — @opencode-ai/sdk does not bundle one, it cross-spawns `opencode` from
PATH — and only e2e-tui.test.ts degrades to a skip without it; the other two
fail outright. The npm package ships the real binary as platform
optionalDependencies, so `npm ci` now yields a runnable suite with nothing
installed globally and no curl-pipe-bash anywhere. The `test` script points
OPENCODE_BIN and PATH at node_modules/.bin so both CI and a fresh clone pick
it up.

Verified: `npm run typecheck` and `npm run build` pass with @opencode-ai/plugin
resolving to 1.18.11 (the manifest floor is ^1.4.11), so the WorkspaceAdapter /
experimental_workspace surface the plugin builds on is still intact upstream.
`npm test` on a PATH stripped of any global opencode gives 1 pass / 10 skip /
0 fail — the skips are the live tests wanting DAYTONA_API_KEY.
The README arrived describing a package published to npm from the Daytona
monorepo. Neither is true here, so:

- installation now uses a `file://` plugin spec against a local checkout,
  since this app is not published; the symlink recipe stays as the alternative
- development setup is `git clone integrations` + `npm ci`, replacing the
  yarn + `npx nx run opencode-plugin:{test,build,publish}` commands
- the "Migrating from v0.167.0" section became "Relationship to
  @daytona/opencode": the two plugins now coexist in this repo rather than
  superseding each other by version, so the useful thing to document is how
  they differ and which to reach for
- documented the /tmp/daytona-plugin.log debug file
- corrected paths (libs/opencode-plugin -> apps/opencode-workspaces-plugin)
  and the project-structure tree

The testing section gets a per-file table of what each test needs and covers,
and states plainly that nothing runs the suite in CI. Missing prerequisites
make these tests skip rather than fail, so a green run proves very little on
its own — the table exists so the skip count can be read against something.

Single-file runs go through `npm test -- <file>` rather than bare `bun test`.
integration.test.ts and e2e-tui.test.ts launch the binary by absolute path
(OPENCODE_BIN, defaulting to ~/.opencode/bin/opencode), which only the `test`
script sets; calling bun directly makes them look for a global install a fresh
clone does not have, and both fail quietly when it is missing — one waits 60s
and blames the server, the other just skips.

Prose is unwrapped to one line per paragraph, matching the other three
READMEs in apps/.

Also lists the app in the root README's Apps table.
…test.ts

Despite its name, this test never touched the plugin. It inlined a ~115-line
copy of the workspace adaptor into a temp project and ran OpenCode against
that. The copy had drifted from the real plugin and was missing two fixes
that matter:

- the create() error path, so a failure leaked its sandbox instead of
  deleting it
- withTimeout() on the health polls, so a command issued during opencode's
  first-start DB migration hung instead of failing

Together those produced the observed behaviour: a ~300s hang ending in a
client-side fetch timeout, with a running sandbox left behind. A copy of the
thing under test proves nothing about the thing under test.

Load the real plugin through a file:// spec in the temp project's
opencode.json, the way plugin.test.ts already does. The temp-project
`npm install` goes with it — the plugin's imports resolve from this package's
node_modules, since the spec points inside it.

Two further fixes the switch exposed:

- the repo was built on whatever init.defaultBranch gives (main on current
  git) while the workspace asked for `master`. The real plugin passes that to
  `git clone --branch`, so it could never have cloned. Both sides now use
  TEST_BRANCH, forced with `git branch -M` so the host's git config cannot
  change the outcome.
- afterAll only knew about a sandbox once the create request came back, so a
  hang left nothing recording what to delete. It now also parses
  `create: start name=` out of the plugin's debug log, which is written before
  the sandbox is provisioned. Same trick e2e-tui.test.ts uses.

Verified live: 2 pass / 0 fail in 25s, down from a 300s hang. Step 5 now lists
README.md, index.ts, package.json, src/ and .git inside the sandbox — the repo
upload only exists in the real plugin, the copy just made an empty directory.
The plugin log shows create -> "server healthy; done" and a matching
"remove: deleted sandbox", so create() and remove() are both covered now.
Sandbox count returned to its pre-run baseline.
…e_modules

The tests defaulted OPENCODE_BIN to ~/.opencode/bin/opencode — a global install
— and the npm `test` script papered over that by exporting OPENCODE_BIN and
PATH. So the suite only worked through one invocation path. Calling `bun test`
directly, which is the obvious thing to do, broke on a fresh clone, and broke
quietly: integration.test.ts waited 60s and blamed the server, e2e-tui.test.ts
skipped its whole file and reported success.

Invert it. `opencode-ai` is already a devDependency shipping the binary as
platform optionalDependencies, so default to that and drop the env wiring:

  process.env.OPENCODE_BIN || resolve(import.meta.dir, '../node_modules/.bin/opencode')

OPENCODE_BIN still wins when set, so pointing at a local OpenCode build is
unchanged. plugin.test.ts needs nothing — it goes through PATH, which bun and
npm both populate from node_modules/.bin. The `test` script collapses to
`bun test --timeout 180000`, and the README loses its caveat paragraph rather
than having to explain a footgun.

Verified with no global opencode reachable at all (PATH stripped, HOME pointing
at an empty dir):

  bun test test/integration.test.ts   2 pass / 0 fail   (was: "Server did not start")
  bun test test/e2e-tui.test.ts       runs the test     (was: 0 pass / 3 skip)

e2e-tui still fails its final magic-token assertion, unchanged by this and
tracked separately — the point here is that it now runs instead of silently
skipping. Both runs cleaned up their sandboxes; the account is back to zero
opencode-* sandboxes.
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA. ✅ Thank you!
Posted by the CLA Assistant Lite bot.

jamesmurdza and others added 3 commits August 2, 2026 22:17
… in workspaces

The e2e test provisioned a healthy sandbox but the remote never produced an
assistant reply. Two root causes:

1. Pinned OPENCODE_VERSION (1.15.13) ships no built-in "opencode" Zen provider
   models (e.g. opencode/big-pickle), so a session created with one silently
   produces no reply — the server is healthy but has no usable model. Bump the
   pin to 1.18.11 to match recent host builds.

2. opencode proxies every remote session request with `?directory=<host
   worktree>` in the URL, and the server resolves the directory from that query
   param before the x-opencode-directory header (searchParams "directory" ||
   header || cwd). The host path (/tmp/…, /Users/…) does not exist in the
   sandbox, so the remote session dies immediately with
   `FileSystem.realPath ENOENT`. Symlink the host worktree path to REPO_PATH
   during create() so that query param resolves to the extracted repo.

With both fixes the e2e test passes: the remote generates the reply and it
even renders back in the host TUI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… symlink

Reference opencode PR anomalyco/opencode#40136, which strips the host
`directory` query param before proxying to the remote — the upstream fix for
the bug the worktree->REPO_PATH symlink works around. Once it ships in a
release and OPENCODE_VERSION is bumped to include it, the symlink can be
removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jamesmurdza

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Aug 5, 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