TL;DR — ipollowork-server unifies session reads across OpenCode and DeepSeek Harness,
but there is no engine-agnostic way to create a session or send a prompt. I'd like to add
one, mirroring the engine adapter the browser already has. Disclosure up front: I've
already built it on a fork branch, and I'm posting this first because I'd rather throw it
away than land the wrong shape. "Don't build this" is a real answer. The specific questions
are at the bottom.
The gap
I wanted to drive iPolloWork from CI — submit a goal, stream the run, answer tool
permissions, collect the result — with no desktop UI. More of that already works than I
expected: the server ships standalone, apps/orchestrator runs headless, and the scoped
token model is real.
Where I got stuck: routes/sessions.ts unifies list/get/messages/snapshot across both
engines, but there's no unified create or prompt. The one unified write that exists says
the problem out loud — DELETE /workspace/:id/sessions/:sessionId hard-codes
501 session_delete_unsupported for Harness (sessions.ts:234), because the two engines
couldn't be reconciled at that point.
Both engines can be written to, just not through one contract: OpenCode via the
/opencode/* proxy (its private API, no compatibility promise), Harness via the JSON-RPC
allowlist in routes/deepseek-harness.ts:14. Two dialects, neither a stable public surface.
Two smaller things follow from the same root:
- The only OpenAPI document in the tree (
packages/docs/openapi.json) is the cloud
Den API, not the local server.
GET /w/:id/capabilities exists (core.ts:215) but its handler takes no ctx at all —
it discards :id and returns server-wide flags, with nothing about the engine. So a
caller can't ask what a given workspace's engine actually supports.
The direction I'd like to check
I don't think this needs a new abstraction, because two pieces are already here and
half-finished:
1. server-route is a declared contribution type with no runtime consumer.
packages/types/src/plugins.ts:63 lists it in contributionTypeSchema. Its only other
appearance is apps/app/src/app/extensions.ts:195 — a descriptive entry pointing at a route
hardcoded in routes/core.ts:582. Nothing reads it to mount anything.
2. ConversationEngineAdapter exists in the browser but not on the server.
apps/app/src/react-app/domains/session/engine/conversation-engine.ts defines the whole
thing — normalized event union (:129), connection interface (:175), adapter (:217),
registry (:228) — with working OpenCode and Harness implementations beside it. That is
precisely what the server lacks, and why writes can't be unified today.
So: mirror the browser's engine adapter onto the server, and organize the resulting
endpoints as declarative modules — the shape server-route already anticipates. Each module
declares its operations once; a registry turns that single declaration into both the route
table and the OpenAPI document, so the two can't drift. Legacy routes stay exactly where they
are.
The objection I'd raise if I were you
This duplicates the browser's engine layer. opencode-conversation-engine.ts +
deepseek-harness-conversation-engine.ts and their mappers are ~1,500 lines, and a
server-side mirror means two copies of the same wire-format knowledge, drifting
independently. Concretely, my branch already copies the Harness internal-<system>-block
stripper out of deepseek-harness-conversation-mapper.ts.
I went ahead anyway because the alternative — the server proxying an engine's private API
and calling that a public contract — seemed worse. But if you'd rather see the mapping
extracted into packages/ and shared by both, that's a better end state than what I built,
and I'd rather do that than have two copies. It does mean touching browser code, which is
why I'm asking rather than assuming.
The related worry is the one README.md:171 names under "Architecture boundary" — OpenCode
staying independently upgradable. Worth saying plainly: today apps/server contains exactly
one OpenCode event-name string (in toy-ui.ts); my adapter adds about thirty. That's a real
increase in coupling surface, concentrated in one file rather than spread out, but I won't
pretend it's zero.
Also, independent of all this
serve-node.ts never wires request.signal into the Request it builds, and its write loop
releases the response reader rather than cancelling it. This is a live leak on the shipped
path, not a hypothetical: GET /workspace/:id/engine/deepseek-harness/events/:stream already
forwards ctx.request.signal into runtime.events(...) → fetch(..., { signal }), so both
ends are wired and only the middle is missing. Every closed Harness event stream leaves its
upstream subscription alive for the life of the process.
I have a fix with three end-to-end tests. I'll file it as its own issue and PR — it's a
bug on main and shouldn't ride along with an API proposal.
Questions
- Is a server-side engine adapter the right call, or should the public API stay
engine-specific and let callers target OpenCode directly?
- If yes — shared mapping in
packages/, or a server-side copy? I'd prefer shared, but
it touches browser code and that's your call.
- Is a versioned API already on the roadmap? If so I'd rather contribute to that than
propose a parallel one.
- How would you want it split? My branch is 61 files in one commit, which is too much to
review at once. Note that "OpenAPI generation first" doesn't actually work — the mount
builds the engine registry unconditionally — so my best guess is: engine adapter +
sessions, then OpenAPI generation, then tasks/webhooks/policy, then the legacy compat
layer last, since that one adds the most routes and deserves its own review.
Happy to open the PR, split it, rework it as shared mapping, or drop it if it doesn't fit.
TL;DR —
ipollowork-serverunifies session reads across OpenCode and DeepSeek Harness,but there is no engine-agnostic way to create a session or send a prompt. I'd like to add
one, mirroring the engine adapter the browser already has. Disclosure up front: I've
already built it on a fork branch, and I'm posting this first because I'd rather throw it
away than land the wrong shape. "Don't build this" is a real answer. The specific questions
are at the bottom.
The gap
I wanted to drive iPolloWork from CI — submit a goal, stream the run, answer tool
permissions, collect the result — with no desktop UI. More of that already works than I
expected: the server ships standalone,
apps/orchestratorruns headless, and the scopedtoken model is real.
Where I got stuck:
routes/sessions.tsunifies list/get/messages/snapshot across bothengines, but there's no unified
createorprompt. The one unified write that exists saysthe problem out loud —
DELETE /workspace/:id/sessions/:sessionIdhard-codes501 session_delete_unsupportedfor Harness (sessions.ts:234), because the two enginescouldn't be reconciled at that point.
Both engines can be written to, just not through one contract: OpenCode via the
/opencode/*proxy (its private API, no compatibility promise), Harness via the JSON-RPCallowlist in
routes/deepseek-harness.ts:14. Two dialects, neither a stable public surface.Two smaller things follow from the same root:
packages/docs/openapi.json) is the cloudDen API, not the local server.
GET /w/:id/capabilitiesexists (core.ts:215) but its handler takes noctxat all —it discards
:idand returns server-wide flags, with nothing about the engine. So acaller can't ask what a given workspace's engine actually supports.
The direction I'd like to check
I don't think this needs a new abstraction, because two pieces are already here and
half-finished:
1.
server-routeis a declared contribution type with no runtime consumer.packages/types/src/plugins.ts:63lists it incontributionTypeSchema. Its only otherappearance is
apps/app/src/app/extensions.ts:195— a descriptive entry pointing at a routehardcoded in
routes/core.ts:582. Nothing reads it to mount anything.2.
ConversationEngineAdapterexists in the browser but not on the server.apps/app/src/react-app/domains/session/engine/conversation-engine.tsdefines the wholething — normalized event union (
:129), connection interface (:175), adapter (:217),registry (
:228) — with working OpenCode and Harness implementations beside it. That isprecisely what the server lacks, and why writes can't be unified today.
So: mirror the browser's engine adapter onto the server, and organize the resulting
endpoints as declarative modules — the shape
server-routealready anticipates. Each moduledeclares its operations once; a registry turns that single declaration into both the route
table and the OpenAPI document, so the two can't drift. Legacy routes stay exactly where they
are.
The objection I'd raise if I were you
This duplicates the browser's engine layer.
opencode-conversation-engine.ts+deepseek-harness-conversation-engine.tsand their mappers are ~1,500 lines, and aserver-side mirror means two copies of the same wire-format knowledge, drifting
independently. Concretely, my branch already copies the Harness internal-
<system>-blockstripper out of
deepseek-harness-conversation-mapper.ts.I went ahead anyway because the alternative — the server proxying an engine's private API
and calling that a public contract — seemed worse. But if you'd rather see the mapping
extracted into
packages/and shared by both, that's a better end state than what I built,and I'd rather do that than have two copies. It does mean touching browser code, which is
why I'm asking rather than assuming.
The related worry is the one
README.md:171names under "Architecture boundary" — OpenCodestaying independently upgradable. Worth saying plainly: today
apps/servercontains exactlyone OpenCode event-name string (in
toy-ui.ts); my adapter adds about thirty. That's a realincrease in coupling surface, concentrated in one file rather than spread out, but I won't
pretend it's zero.
Also, independent of all this
serve-node.tsnever wiresrequest.signalinto theRequestit builds, and its write loopreleases the response reader rather than cancelling it. This is a live leak on the shipped
path, not a hypothetical:
GET /workspace/:id/engine/deepseek-harness/events/:streamalreadyforwards
ctx.request.signalintoruntime.events(...)→fetch(..., { signal }), so bothends are wired and only the middle is missing. Every closed Harness event stream leaves its
upstream subscription alive for the life of the process.
I have a fix with three end-to-end tests. I'll file it as its own issue and PR — it's a
bug on
mainand shouldn't ride along with an API proposal.Questions
engine-specific and let callers target OpenCode directly?
packages/, or a server-side copy? I'd prefer shared, butit touches browser code and that's your call.
propose a parallel one.
review at once. Note that "OpenAPI generation first" doesn't actually work — the mount
builds the engine registry unconditionally — so my best guess is: engine adapter +
sessions, then OpenAPI generation, then tasks/webhooks/policy, then the legacy compat
layer last, since that one adds the most routes and deserves its own review.
Happy to open the PR, split it, rework it as shared mapping, or drop it if it doesn't fit.