Explorar o código

Merge branch 'dev' into fix-effect-context-bridges

Kit Langton hai 2 días
pai
achega
eeca56a943

+ 9 - 0
bun.lock

@@ -498,6 +498,13 @@
         "typescript": "catalog:",
       },
     },
+    "packages/server": {
+      "name": "@opencode-ai/server",
+      "version": "1.4.3",
+      "devDependencies": {
+        "typescript": "catalog:",
+      },
+    },
     "packages/slack": {
       "name": "@opencode-ai/slack",
       "version": "1.4.3",
@@ -1533,6 +1540,8 @@
 
     "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
 
+    "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
+
     "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
 
     "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],

+ 18 - 19
nix/opencode.nix

@@ -7,7 +7,6 @@
   sysctl,
   makeBinaryWrapper,
   models-dev,
-  ripgrep,
   installShellFiles,
   versionCheckHook,
   writableTmpDirAsHomeHook,
@@ -52,25 +51,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
     runHook postBuild
   '';
 
-  installPhase = ''
-    runHook preInstall
-
-    install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
-    install -Dm644 schema.json $out/share/opencode/schema.json
-
-    wrapProgram $out/bin/opencode \
-      --prefix PATH : ${
-        lib.makeBinPath (
-          [
-            ripgrep
+  installPhase =
+    ''
+      runHook preInstall
+
+      install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
+      install -Dm644 schema.json $out/share/opencode/schema.json
+    ''
+    # bun runs sysctl to detect if dunning on rosetta2
+    + lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
+      wrapProgram $out/bin/opencode \
+        --prefix PATH : ${
+          lib.makeBinPath [
+            sysctl
           ]
-          # bun runs sysctl to detect if dunning on rosetta2
-          ++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
-        )
-      }
-
-    runHook postInstall
-  '';
+        }
+    ''
+    + ''
+      runHook postInstall
+    '';
 
   postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
     # trick yargs into also generating zsh completions

+ 2 - 2
packages/console/app/src/routes/zen/util/handler.ts

@@ -106,7 +106,7 @@ export async function handler(
     const zenData = ZenData.list(opts.modelList)
     const modelInfo = validateModel(zenData, model)
     const dataDumper = createDataDumper(sessionId, requestId, projectId)
-    const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip)
+    const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
     const trialProviders = await trialLimiter?.check()
     const rateLimiter = createRateLimiter(
       modelInfo.id,
@@ -392,7 +392,7 @@ export async function handler(
   function validateModel(zenData: ZenData, reqModel: string) {
     if (!(reqModel in zenData.models)) throw new ModelError(t("zen.api.error.modelNotSupported", { model: reqModel }))
 
-    const modelId = reqModel as keyof typeof zenData.models
+    const modelId = reqModel
     const modelData = Array.isArray(zenData.models[modelId])
       ? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
       : zenData.models[modelId]

+ 5 - 2
packages/console/app/src/routes/zen/util/provider/openai-compatible.ts

@@ -6,12 +6,14 @@ type Usage = {
   total_tokens?: number
   // used by moonshot
   cached_tokens?: number
-  // used by xai
+  // used by xai & alibaba
   prompt_tokens_details?: {
     text_tokens?: number
     audio_tokens?: number
     image_tokens?: number
     cached_tokens?: number
+    // used by alibaba
+    cache_creation_input_tokens?: number
   }
   completion_tokens_details?: {
     reasoning_tokens?: number
@@ -62,6 +64,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
     const outputTokens = usage.completion_tokens ?? 0
     const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
     let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
+    const cacheWriteTokens = usage.prompt_tokens_details?.cache_creation_input_tokens ?? undefined
 
     if (adjustCacheUsage && !cacheReadTokens) {
       cacheReadTokens = Math.floor(inputTokens * 0.9)
@@ -72,7 +75,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
       outputTokens,
       reasoningTokens,
       cacheReadTokens,
-      cacheWrite5mTokens: undefined,
+      cacheWrite5mTokens: cacheWriteTokens,
       cacheWrite1hTokens: undefined,
     }
   },

+ 65 - 6
packages/console/core/src/model.ts

@@ -26,7 +26,7 @@ export namespace ZenData {
     allowAnonymous: z.boolean().optional(),
     byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
     stickyProvider: z.enum(["strict", "prefer"]).optional(),
-    trialProviders: z.array(z.string()).optional(),
+    trialProvider: z.string().optional(),
     trialEnded: z.boolean().optional(),
     fallbackProvider: z.string().optional(),
     rateLimit: z.number().optional(),
@@ -45,7 +45,7 @@ export namespace ZenData {
 
   const ProviderSchema = z.object({
     api: z.string(),
-    apiKey: z.string(),
+    apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
     format: FormatSchema.optional(),
     headerMappings: z.record(z.string(), z.string()).optional(),
     payloadModifier: z.record(z.string(), z.any()).optional(),
@@ -54,7 +54,10 @@ export namespace ZenData {
   })
 
   const ModelsSchema = z.object({
-    models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
+    zenModels: z.record(
+      z.string(),
+      z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
+    ),
     liteModels: z.record(
       z.string(),
       z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
@@ -99,10 +102,66 @@ export namespace ZenData {
         Resource.ZEN_MODELS29.value +
         Resource.ZEN_MODELS30.value,
     )
-    const { models, liteModels, providers } = ModelsSchema.parse(json)
+    const { zenModels, liteModels, providers } = ModelsSchema.parse(json)
+    const compositeProviders = Object.fromEntries(
+      Object.entries(providers).map(([id, provider]) => [
+        id,
+        typeof provider.apiKey === "string"
+          ? [{ id: id, key: provider.apiKey }]
+          : Object.entries(provider.apiKey).map(([kid, key]) => ({
+              id: `${id}.${kid}`,
+              key,
+            })),
+      ]),
+    )
     return {
-      models: modelList === "lite" ? liteModels : models,
-      providers,
+      providers: Object.fromEntries(
+        Object.entries(providers).flatMap(([providerId, provider]) =>
+          compositeProviders[providerId].map((p) => [p.id, { ...provider, apiKey: p.key }]),
+        ),
+      ),
+      models: (() => {
+        const normalize = (model: z.infer<typeof ModelSchema>) => {
+          const composite = model.providers.find((p) => compositeProviders[p.id].length > 1)
+          if (!composite)
+            return {
+              trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
+            }
+
+          const weightMulti = compositeProviders[composite.id].length
+
+          return {
+            trialProvider: (() => {
+              if (!model.trialProvider) return undefined
+              if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
+              return [model.trialProvider]
+            })(),
+            providers: model.providers.flatMap((p) =>
+              p.id === composite.id
+                ? compositeProviders[p.id].map((sub) => ({
+                    ...p,
+                    id: sub.id,
+                    weight: p.weight ?? 1,
+                  }))
+                : [
+                    {
+                      ...p,
+                      weight: (p.weight ?? 1) * weightMulti,
+                    },
+                  ],
+            ),
+          }
+        }
+
+        return Object.fromEntries(
+          Object.entries(modelList === "lite" ? liteModels : zenModels).map(([modelId, model]) => {
+            const n = Array.isArray(model)
+              ? model.map((m) => ({ ...m, ...normalize(m) }))
+              : { ...model, ...normalize(model) }
+            return [modelId, n]
+          }),
+        )
+      })(),
     }
   })
 }

+ 666 - 0
packages/opencode/specs/effect/server-package.md

@@ -0,0 +1,666 @@
+# Server package extraction
+
+Practical reference for extracting a future `packages/server` from the current `packages/opencode` monolith while `packages/core` is still being migrated to Effect.
+
+This document is intentionally execution-oriented.
+
+It should give an agent enough context to land one incremental PR at a time without needing to rediscover the package strategy, route migration rules, or current constraints.
+
+## Goal
+
+Create `packages/server` as the home for:
+
+- HTTP contract definitions
+- HTTP handler implementations
+- OpenAPI generation
+- eventual embeddable server APIs for Node apps
+
+Do this without blocking on the full `packages/core` extraction.
+
+## Future state
+
+Target package layout:
+
+- `packages/core` - all opencode services, Effect-first source of truth
+- `packages/server` - opencode server, with separate contract and implementation, still producing `openapi.json`
+- `packages/cli` - TUI + CLI entrypoints
+- `packages/sdk` - generated from the server OpenAPI spec, may add higher-level wrappers
+- `packages/plugin` - generated or semi-hand-rolled non-Effect package built from core plugin definitions
+
+Desired user stories:
+
+- import from `core` and build a custom agent or app-specific runtime
+- import from `server` and embed the full opencode server into an existing Node app
+- spawn the CLI and talk to the server through that boundary
+
+## Current state
+
+Everything still lives in `packages/opencode`.
+
+Important current facts:
+
+- there is no `packages/core` or `packages/cli` workspace yet
+- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
+- the main host server is still Hono-based in `src/server/server.ts`
+- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
+- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
+- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
+- that experimental slice is mounted under `/experimental/httpapi/question`
+- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
+
+This means the package split should start from an extraction path, not from greenfield package ownership.
+
+## Structural reference
+
+Use `anomalyco/opentunnel` as the structural reference for `packages/server`.
+
+The important pattern there is:
+
+- `packages/core` owns services and domain schemas
+- `packages/server/src/definition/*` owns pure `HttpApi` contracts
+- `packages/server/src/api/*` owns `HttpApiBuilder.group(...)` implementations and server-side middleware wiring
+- `packages/server/src/index.ts` becomes the composition root only after the server package really owns runtime hosting
+
+Relevant `opentunnel` files:
+
+- `packages/server/src/definition/index.ts`
+- `packages/server/src/definition/tunnel.ts`
+- `packages/server/src/api/index.ts`
+- `packages/server/src/api/tunnel.ts`
+- `packages/server/src/api/client.ts`
+- `packages/server/src/index.ts`
+
+The intended direction here is the same, but the current `opencode` package split is earlier in the migration.
+
+That means:
+
+- we should follow the same `definition` and `api` naming
+- we should keep contract and implementation as separate modules from the start
+- we should postpone the runtime composition root until `packages/core` exists enough to support it cleanly
+
+## Key decision
+
+Start `packages/server` as a contract and implementation package only.
+
+Do not make it the runtime host yet.
+
+Why:
+
+- `packages/core` does not exist yet
+- the current server host still lives in `packages/opencode`
+- moving host ownership immediately would force a large package and runtime shuffle while Effect service extraction is still in flight
+- if `packages/server` imports services from `packages/opencode` while `packages/opencode` imports `packages/server` to host routes, we create a package cycle immediately
+
+Short version:
+
+1. create `packages/server`
+2. move pure `HttpApi` contracts there
+3. move handler factories there
+4. keep `packages/opencode` as the temporary Hono host
+5. merge `packages/server` OpenAPI with the legacy Hono OpenAPI during the transition
+6. move server hosting later, after `packages/core` exists enough
+
+## Dependency rule
+
+Phase 1 rule:
+
+- `packages/server` must not import from `packages/opencode`
+
+Allowed in phase 1:
+
+- `packages/opencode` imports `packages/server`
+- `packages/server` accepts host-provided services, layers, or callbacks as inputs
+- `packages/server` may temporarily own transport-local placeholder schemas when a canonical shared schema does not exist yet
+
+Future rule after `packages/core` exists:
+
+- `packages/server` imports from `packages/core`
+- `packages/cli` imports from `packages/server` and `packages/core`
+- `packages/opencode` shrinks or disappears as package responsibilities are fully split
+
+## HttpApi model
+
+Use Effect v4 `HttpApi` as the source of truth for migrated HTTP routes.
+
+Important properties from the current `effect` / `effect-smol` model:
+
+- `HttpApi`, `HttpApiGroup`, and `HttpApiEndpoint` are pure contract definitions
+- handlers are implemented separately with `HttpApiBuilder.group(...)`
+- OpenAPI can be generated from the contract alone
+- auth and middleware can later be modeled with `HttpApiMiddleware.Service`
+- SSE and websocket routes are not good first-wave `HttpApi` targets
+
+This package split should preserve that separation explicitly.
+
+Default shape for migrated routes:
+
+- contract lives in `packages/server/src/definition/*`
+- implementation lives in `packages/server/src/api/*`
+- host mounting stays outside for now
+
+## OpenAPI rule
+
+During the transition there is still one spec artifact.
+
+Default rule:
+
+- `packages/server` generates OpenAPI from `HttpApi` contract
+- `packages/opencode` keeps generating legacy OpenAPI from Hono routes
+- the temporary exported server spec is a merged document
+- `packages/sdk` continues consuming one `openapi.json`
+
+Merge safety rules:
+
+- fail on duplicate `path + method`
+- fail on duplicate `operationId`
+- prefer explicit summary, description, and operation ids on all new `HttpApi` endpoints
+
+Practical implication:
+
+- do not make the SDK consume two specs
+- do not switch SDK generation to `packages/server` only until enough of the route surface has moved
+
+## Package shape
+
+Minimum viable `packages/server`:
+
+- `src/index.ts`
+- `src/definition/index.ts`
+- `src/definition/api.ts`
+- `src/definition/question.ts`
+- `src/api/index.ts`
+- `src/api/question.ts`
+- `src/openapi.ts`
+- `src/bridge/hono.ts`
+- `src/types.ts`
+
+Later additions, once there is enough real contract surface:
+
+- `src/api/client.ts`
+- runtime composition in `src/index.ts`
+
+Suggested initial exports:
+
+- `api`
+- `openapi`
+- `questionApi`
+- `makeQuestionHandler`
+
+Phase 1 responsibilities:
+
+- own pure API contracts
+- own handler factories for migrated slices
+- own contract-generated OpenAPI
+- expose host adapters needed by `packages/opencode`
+
+Phase 1 non-goals:
+
+- do not own `listen()`
+- do not own adapter selection
+- do not own global server middleware
+- do not own websocket or SSE transport
+- do not own process bootstrapping for CLI entrypoints
+
+## Current source inventory
+
+These files matter for the first phase.
+
+Current host and route composition:
+
+- `src/server/server.ts`
+- `src/server/control/index.ts`
+- `src/server/instance/index.ts`
+- `src/server/middleware.ts`
+- `src/server/adapter.bun.ts`
+- `src/server/adapter.node.ts`
+
+Current experimental `HttpApi` slice:
+
+- `src/server/instance/httpapi/question.ts`
+- `src/server/instance/httpapi/index.ts`
+- `src/server/instance/experimental.ts`
+- `test/server/question-httpapi.test.ts`
+
+Current OpenAPI flow:
+
+- `src/server/server.ts` via `Server.openapi()`
+- `src/cli/cmd/generate.ts`
+- `packages/sdk/js/script/build.ts`
+
+Current runtime and service layer:
+
+- `src/effect/app-runtime.ts`
+- `src/effect/run-service.ts`
+
+## Ownership rules
+
+Move first into `packages/server`:
+
+- the experimental `question` `HttpApi` slice
+- future `provider` and `config` JSON read slices
+- any new `HttpApi` route groups
+- transport-local OpenAPI generation for migrated routes
+
+Keep in `packages/opencode` for now:
+
+- `src/server/server.ts`
+- `src/server/control/index.ts`
+- `src/server/instance/*.ts`
+- `src/server/middleware.ts`
+- `src/server/adapter.*.ts`
+- `src/effect/app-runtime.ts`
+- `src/effect/run-service.ts`
+- all Effect services until they move to `packages/core`
+
+## Placeholder schema rule
+
+`packages/core` is allowed to lag behind.
+
+Until shared canonical schemas move to `packages/core`:
+
+- prefer importing existing Effect Schema DTOs from current locations when practical
+- if a route only needs a transport-local type and moving the canonical schema would create unrelated churn, allow a temporary server-local placeholder schema
+- if a placeholder is introduced, leave a short note so it does not become permanent
+
+The default rule from `schema.md` still applies:
+
+- Effect Schema owns the type
+- `.zod` is compatibility only
+- avoid parallel hand-written Zod and Effect definitions for the same migrated route shape
+
+## Host boundary rule
+
+Until host ownership moves:
+
+- auth stays at the outer Hono app level
+- compression stays at the outer Hono app level
+- CORS stays at the outer Hono app level
+- instance and workspace lookup stay at the current middleware layer
+- `packages/server` handlers should assume the host already provided the right request context
+- do not redesign host middleware just to land the package split
+
+This matches the current guidance in `http-api.md`:
+
+- keep auth outside the first parallel `HttpApi` slices
+- keep instance lookup outside the first parallel `HttpApi` slices
+- keep the first migrations transport-focused and semantics-preserving
+
+## Route selection rules
+
+Good early migration targets:
+
+- `question`
+- `provider` auth read endpoint
+- `config` providers read endpoint
+- small read-only instance routes
+
+Bad early migration targets:
+
+- `session`
+- `event`
+- `pty`
+- most `global` streaming or process-heavy routes
+- anything requiring websocket upgrade handling
+- anything that mixes many mutations and streaming in one file
+
+## First vertical slice
+
+The first slice for the package split is the existing experimental `question` group.
+
+Why `question` first:
+
+- it already exists as an experimental `HttpApi` slice
+- it already follows the desired contract and implementation split in one file
+- it is already mounted through the current Hono host
+- it already has an end-to-end test
+- it is JSON-only
+- it has low blast radius
+
+Use the first slice to prove:
+
+- package boundary
+- contract and implementation split
+- host mounting from `packages/opencode`
+- merged OpenAPI output
+- test ergonomics for future slices
+
+Do not broaden scope in the first slice.
+
+## Incremental migration order
+
+Use small PRs.
+
+Each PR should be easy to review, easy to revert, and should not mix extraction work with unrelated service refactors.
+
+### PR 1. Create `packages/server`
+
+Scope:
+
+- add the new workspace package
+- add package manifest and tsconfig
+- add empty `src/index.ts`, `src/definition/api.ts`, `src/definition/index.ts`, `src/api/index.ts`, `src/openapi.ts`, and supporting scaffolding
+
+Rules:
+
+- no production behavior changes
+- no host server changes yet
+- no imports from `packages/opencode` inside `packages/server`
+- prefer `opentunnel`-style naming from the start: `definition` for contracts, `api` for implementations
+
+Done means:
+
+- `packages/server` typechecks
+- the workspace can import it
+- the package boundary is in place for follow-up PRs
+
+### PR 2. Move the experimental question contract
+
+Scope:
+
+- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
+- place it in `packages/server/src/definition/question.ts`
+- aggregate it in `packages/server/src/definition/api.ts`
+- generate OpenAPI in `packages/server/src/openapi.ts`
+
+Rules:
+
+- contract only in this PR
+- no handler movement yet if that keeps the diff simpler
+- keep operation ids and docs metadata stable
+
+Done means:
+
+- question contract lives in `packages/server`
+- OpenAPI can be generated from contract alone
+- no runtime behavior changes yet
+
+### PR 3. Move the experimental question handler factory
+
+Scope:
+
+- extract the question `HttpApiBuilder.group(...)` implementation into `packages/server/src/api/question.ts`
+- expose it as a factory that accepts host-provided dependencies or wiring
+- add a small Hono bridge in `packages/server/src/bridge/hono.ts` if needed
+
+Rules:
+
+- `packages/server` must still not import from `packages/opencode`
+- handler code should stay thin and service-delegating
+- do not redesign the question service itself in this PR
+
+Done means:
+
+- `packages/server` can produce the experimental question handler
+- the package still stays cycle-free
+
+### PR 4. Mount `packages/server` question from `packages/opencode`
+
+Scope:
+
+- replace local experimental question route wiring in `packages/opencode`
+- keep the same mount path:
+- `/experimental/httpapi/question`
+- `/experimental/httpapi/question/doc`
+
+Rules:
+
+- no behavior change
+- preserve existing docs path
+- preserve current request and response shapes
+
+Done means:
+
+- existing question `HttpApi` test still passes
+- runtime behavior is unchanged
+- the current host server is now consuming `packages/server`
+
+### PR 5. Merge legacy and contract OpenAPI
+
+Scope:
+
+- keep `Server.openapi()` as the temporary spec entrypoint
+- generate legacy Hono spec
+- generate `packages/server` contract spec
+- merge them into one document
+- keep `cli/cmd/generate.ts` and `packages/sdk/js/script/build.ts` consuming one spec
+
+Rules:
+
+- fail loudly on duplicate `path + method`
+- fail loudly on duplicate `operationId`
+- do not silently overwrite one source with the other
+
+Done means:
+
+- one merged spec is produced
+- migrated question paths can come from `packages/server`
+- existing SDK generation path still works
+
+### PR 6. Add merged OpenAPI coverage
+
+Scope:
+
+- add one test for merged OpenAPI
+- assert both a legacy Hono route and a migrated `HttpApi` route exist
+
+Rules:
+
+- test the merged document, not just the `packages/server` contract spec in isolation
+- pick one stable legacy route and one stable migrated route
+
+Done means:
+
+- the merged-spec path is covered
+- future route migrations have a guardrail
+
+### PR 7. Migrate `GET /provider/auth`
+
+Scope:
+
+- add `GET /provider/auth` as the next `HttpApi` slice in `packages/server`
+- mount it in parallel from `packages/opencode`
+
+Why this route:
+
+- JSON-only
+- simple service delegation
+- small response shape
+- already listed as the best next `provider` candidate in `http-api.md`
+
+Done means:
+
+- route works through the current host
+- route appears in merged OpenAPI
+- no semantic change to provider auth behavior
+
+### PR 8. Migrate `GET /config/providers`
+
+Scope:
+
+- add `GET /config/providers` as a `HttpApi` slice in `packages/server`
+- mount it in parallel from `packages/opencode`
+
+Why this route:
+
+- JSON-only
+- read-only
+- low transport complexity
+- already listed as the best next `config` candidate in `http-api.md`
+
+Done means:
+
+- route works unchanged
+- route appears in merged OpenAPI
+
+### PR 9+. Migrate small read-only instance routes
+
+Candidate order:
+
+1. `GET /path`
+2. `GET /vcs`
+3. `GET /vcs/diff`
+4. `GET /command`
+5. `GET /agent`
+6. `GET /skill`
+
+Rules:
+
+- one or two endpoints per PR
+- prefer read-only routes first
+- keep outer middleware unchanged
+- keep business logic in the existing service layer
+
+Done means for each PR:
+
+- contract lives in `packages/server`
+- handler lives in `packages/server`
+- route is mounted from the current host
+- route appears in merged OpenAPI
+- behavior remains unchanged
+
+### Later PR. Move host ownership into `packages/server`
+
+Only start this after there is enough `packages/core` surface to depend on directly.
+
+Scope:
+
+- move server composition into `packages/server`
+- add embeddable APIs such as `createServer(...)`, `listen(...)`, or `createApp(...)`
+- move adapter selection and server startup out of `packages/opencode`
+
+Rules:
+
+- do not start this while `packages/server` still depends on `packages/opencode`
+- do not mix this with route migration PRs
+
+Done means:
+
+- `packages/server` can be embedded in another Node app
+- `packages/cli` can depend on `packages/server`
+- host logic no longer lives in `packages/opencode`
+
+## PR sizing rule
+
+Every migration PR should satisfy all of these:
+
+- one route group or one to two endpoints
+- no unrelated service refactor
+- no auth redesign
+- no middleware redesign
+- OpenAPI updated
+- at least one route test or spec test added or updated
+
+## Done means for a migrated route group
+
+A route group migration is complete only when:
+
+1. the `HttpApi` contract lives in `packages/server`
+2. handler implementation lives in `packages/server`
+3. the route is mounted from the current host in `packages/opencode`
+4. the route appears in merged OpenAPI
+5. request and response schemas are Effect Schema-first or clearly temporary placeholders
+6. existing behavior remains unchanged
+7. the route has straightforward test coverage
+
+## Validation expectations
+
+For package-split PRs, validate the smallest useful thing.
+
+Typical validation for the first waves:
+
+- `bun typecheck` in the touched package directory or directories
+- the relevant route test, especially `test/server/question-httpapi.test.ts`
+- merged OpenAPI coverage if the PR touches spec generation
+
+Do not run tests from repo root.
+
+## Main risks
+
+### Package cycle
+
+This is the biggest risk.
+
+Bad state:
+
+- `packages/server` imports services or runtime from `packages/opencode`
+- `packages/opencode` imports route definitions or handlers from `packages/server`
+
+Avoid by:
+
+- keeping phase-1 `packages/server` free of `packages/opencode` imports
+- using factories and host-provided wiring instead of direct service imports
+
+### Spec drift
+
+During the transition there are two route-definition sources.
+
+Avoid by:
+
+- one merged spec
+- collision checks
+- explicit `operationId`s
+- merged OpenAPI tests
+
+### Middleware mismatch
+
+Current auth, compression, CORS, and instance selection are Hono-centered.
+
+Avoid by:
+
+- leaving them where they are during the first wave
+- not trying to solve `HttpApiMiddleware.Service` globally in the package-split PRs
+
+### Core lag
+
+`packages/core` will not be ready everywhere.
+
+Avoid by:
+
+- allowing small transport-local placeholder schemas where necessary
+- keeping those placeholders clearly temporary
+- not blocking the server extraction on full schema movement
+
+### Scope creep
+
+The first vertical slice is easy to overload.
+
+Avoid by:
+
+- proving the package boundary first
+- not mixing package creation, route migration, host redesign, and core extraction in the same change
+
+## Non-goals for the first wave
+
+- do not replace all Hono routes at once
+- do not migrate SSE or websocket routes first
+- do not redesign auth
+- do not redesign instance lookup
+- do not wait for full `packages/core` before starting `packages/server`
+- do not change SDK generation to consume multiple specs
+
+## Checklist
+
+- [x] create `packages/server`
+- [x] add package-level exports for contract and OpenAPI
+- [ ] extract `question` contract into `packages/server`
+- [ ] extract `question` handler factory into `packages/server`
+- [ ] mount `question` from `packages/opencode`
+- [ ] merge legacy and contract OpenAPI into one document
+- [ ] add merged-spec coverage
+- [ ] migrate `GET /provider/auth`
+- [ ] migrate `GET /config/providers`
+- [ ] migrate small read-only instance routes one or two at a time
+- [ ] move host ownership into `packages/server` only after `packages/core` is ready enough
+- [ ] split `packages/cli` after server and core boundaries are stable
+
+## Rule of thumb
+
+The fastest correct path is:
+
+1. establish `packages/server` as the contract-first boundary
+2. keep `packages/opencode` as the temporary host
+3. migrate a few safe JSON routes
+4. keep one merged OpenAPI document
+5. move actual host ownership only after `packages/core` can support it cleanly
+
+If a proposed PR would make `packages/server` import from `packages/opencode`, stop and restructure the boundary first.

+ 23 - 2
packages/opencode/src/effect/app-runtime.ts

@@ -1,5 +1,5 @@
 import { Layer, ManagedRuntime } from "effect"
-import { memoMap } from "./run-service"
+import { attach, memoMap } from "./run-service"
 import { Observability } from "./oltp"
 
 import { AppFileSystem } from "@/filesystem"
@@ -97,4 +97,25 @@ export const AppLayer = Layer.mergeAll(
   SessionShare.defaultLayer,
 )
 
-export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
+const rt = ManagedRuntime.make(AppLayer, { memoMap })
+type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
+const wrap = (effect: Parameters<typeof rt.runSync>[0]) => attach(effect as never) as never
+
+export const AppRuntime: Runtime = {
+  runSync(effect) {
+    return rt.runSync(wrap(effect))
+  },
+  runPromise(effect, options) {
+    return rt.runPromise(wrap(effect), options)
+  },
+  runPromiseExit(effect, options) {
+    return rt.runPromiseExit(wrap(effect), options)
+  },
+  runFork(effect) {
+    return rt.runFork(wrap(effect))
+  },
+  runCallback(effect) {
+    return rt.runCallback(wrap(effect))
+  },
+  dispose: () => rt.dispose(),
+}

+ 5 - 1
packages/opencode/src/session/llm.ts

@@ -205,7 +205,11 @@ export namespace LLM {
           // calls but no tools param is present. When there are no active tools (e.g.
           // during compaction), inject a stub tool to satisfy the validation requirement.
           // The stub description explicitly tells the model not to call it.
-          if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
+          if (
+            (isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
+            Object.keys(tools).length === 0 &&
+            hasToolCalls(input.messages)
+          ) {
             tools["_noop"] = tool({
               description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
               inputSchema: jsonSchema({

+ 19 - 12
packages/opencode/src/session/prompt.ts

@@ -104,12 +104,21 @@ export namespace SessionPrompt {
       const summary = yield* SessionSummary.Service
       const sys = yield* SystemPrompt.Service
       const llm = yield* LLM.Service
-      const ctx = yield* Effect.context()
-
-      const run = {
-        promise: <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(ctx)(effect),
-        fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runForkWith(ctx)(effect),
-      }
+      const runner = Effect.fn("SessionPrompt.runner")(function* () {
+        const ctx = yield* Effect.context()
+        return {
+          promise: <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(ctx)(effect),
+          fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runForkWith(ctx)(effect),
+        }
+      })
+      const ops = Effect.fn("SessionPrompt.ops")(function* () {
+        const run = yield* runner()
+        return {
+          cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
+          resolvePromptParts: (template: string) => resolvePromptParts(template),
+          prompt: (input: PromptInput) => prompt(input),
+        } satisfies TaskPromptOps
+      })
 
       const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
         yield* elog.info("cancel", { sessionID })
@@ -359,6 +368,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       }) {
         using _ = log.time("resolveTools")
         const tools: Record<string, AITool> = {}
+        const run = yield* runner()
+        const promptOps = yield* ops()
 
         const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
           sessionID: input.session.id,
@@ -528,6 +539,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       }) {
         const { task, model, lastUser, sessionID, session, msgs } = input
         const ctx = yield* InstanceState.context
+        const promptOps = yield* ops()
         const { task: taskTool } = yield* registry.named()
         const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
         const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
@@ -712,6 +724,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
 
       const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
         const ctx = yield* InstanceState.context
+        const run = yield* runner()
         const session = yield* sessions.get(input.sessionID)
         if (session.revert) {
           yield* revert.cleanup(session)
@@ -1659,12 +1672,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
         return result
       })
 
-      const promptOps: TaskPromptOps = {
-        cancel: (sessionID) => run.fork(cancel(sessionID)),
-        resolvePromptParts: (template) => resolvePromptParts(template),
-        prompt: (input) => prompt(input),
-      }
-
       return Service.of({
         cancel,
         prompt,

+ 19 - 0
packages/opencode/test/effect/app-runtime-logger.test.ts

@@ -1,8 +1,11 @@
 import { expect, test } from "bun:test"
 import { Context, Effect, Layer, Logger } from "effect"
 import { AppRuntime } from "../../src/effect/app-runtime"
+import { InstanceRef } from "../../src/effect/instance-ref"
 import { EffectLogger } from "../../src/effect/logger"
 import { makeRuntime } from "../../src/effect/run-service"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
 
 function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
   return {
@@ -40,3 +43,19 @@ test("AppRuntime also installs EffectLogger through Observability.layer", async
   expect(current.effectLogger).toBe(true)
   expect(current.defaultLogger).toBe(false)
 })
+
+test("AppRuntime attaches InstanceRef from ALS", async () => {
+  await using tmp = await tmpdir({ git: true })
+
+  const dir = await Instance.provide({
+    directory: tmp.path,
+    fn: () =>
+      AppRuntime.runPromise(
+        Effect.gen(function* () {
+          return (yield* InstanceRef)?.directory
+        }),
+      ),
+  })
+
+  expect(dir).toBe(tmp.path)
+})

+ 42 - 0
packages/opencode/test/session/prompt-effect.test.ts

@@ -483,6 +483,48 @@ it.live("loop continues when finish is tool-calls", () =>
   ),
 )
 
+it.live("glob tool keeps instance context during prompt runs", () =>
+  provideTmpdirServer(
+    ({ dir, llm }) =>
+      Effect.gen(function* () {
+        const prompt = yield* SessionPrompt.Service
+        const sessions = yield* Session.Service
+        const session = yield* sessions.create({
+          title: "Glob context",
+          permission: [{ permission: "*", pattern: "*", action: "allow" }],
+        })
+        const file = path.join(dir, "probe.txt")
+        yield* Effect.promise(() => Bun.write(file, "probe"))
+
+        yield* prompt.prompt({
+          sessionID: session.id,
+          agent: "build",
+          noReply: true,
+          parts: [{ type: "text", text: "find text files" }],
+        })
+        yield* llm.tool("glob", { pattern: "**/*.txt" })
+        yield* llm.text("done")
+
+        const result = yield* prompt.loop({ sessionID: session.id })
+        expect(result.info.role).toBe("assistant")
+
+        const msgs = yield* MessageV2.filterCompactedEffect(session.id)
+        const tool = msgs
+          .flatMap((msg) => msg.parts)
+          .find(
+            (part): part is CompletedToolPart =>
+              part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
+          )
+        if (!tool) return
+
+        expect(tool.state.output).toContain(file)
+        expect(tool.state.output).not.toContain("No context found for instance")
+        expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
+      }),
+    { git: true, config: providerCfg },
+  ),
+)
+
 it.live("loop continues when finish is stop but assistant has tool parts", () =>
   provideTmpdirServer(
     Effect.fnUntraced(function* ({ llm }) {

+ 24 - 0
packages/server/package.json

@@ -0,0 +1,24 @@
+{
+  "$schema": "https://json.schemastore.org/package.json",
+  "name": "@opencode-ai/server",
+  "version": "1.4.3",
+  "type": "module",
+  "license": "MIT",
+  "exports": {
+    ".": "./src/index.ts",
+    "./openapi": "./src/openapi.ts",
+    "./definition": "./src/definition/index.ts",
+    "./definition/api": "./src/definition/api.ts",
+    "./api": "./src/api/index.ts"
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "typecheck": "tsc --noEmit",
+    "build": "tsc"
+  },
+  "devDependencies": {
+    "typescript": "catalog:"
+  }
+}

+ 1 - 0
packages/server/src/api/index.ts

@@ -0,0 +1 @@
+export {}

+ 6 - 0
packages/server/src/definition/api.ts

@@ -0,0 +1,6 @@
+import type { ServerApi } from "../types.js"
+
+export const api: ServerApi = {
+  name: "opencode",
+  groups: [],
+}

+ 1 - 0
packages/server/src/definition/index.ts

@@ -0,0 +1 @@
+export { api } from "./api.js"

+ 3 - 0
packages/server/src/index.ts

@@ -0,0 +1,3 @@
+export { openapi } from "./openapi.js"
+export { api } from "./definition/api.js"
+export type { OpenApiSpec, ServerApi } from "./types.js"

+ 14 - 0
packages/server/src/openapi.ts

@@ -0,0 +1,14 @@
+import { api } from "./definition/api.js"
+import type { OpenApiSpec } from "./types.js"
+
+export function openapi(): OpenApiSpec {
+  return {
+    openapi: "3.1.1",
+    info: {
+      title: api.name,
+      version: "0.0.0",
+      description: "Contract-first server package scaffold.",
+    },
+    paths: {},
+  }
+}

+ 14 - 0
packages/server/src/types.ts

@@ -0,0 +1,14 @@
+export interface ServerApi {
+  readonly name: string
+  readonly groups: readonly string[]
+}
+
+export interface OpenApiSpec {
+  readonly openapi: string
+  readonly info: {
+    readonly title: string
+    readonly version: string
+    readonly description: string
+  }
+  readonly paths: Record<string, never>
+}

+ 15 - 0
packages/server/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig.json",
+  "compilerOptions": {
+    "target": "ES2022",
+    "rootDir": "src",
+    "outDir": "dist",
+    "module": "nodenext",
+    "declaration": true,
+    "moduleResolution": "nodenext",
+    "lib": ["es2022", "dom", "dom.iterable"],
+    "strict": true,
+    "skipLibCheck": true
+  },
+  "include": ["src"]
+}