From 8a8ee1a89478af2684e55b73b3363a695f24e417 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 16:36:04 -0400 Subject: [PATCH 1/2] refactor(session): extract sharing orchestration --- packages/opencode/src/cli/cmd/github.ts | 3 +- .../opencode/src/server/routes/session.ts | 11 +-- packages/opencode/src/session/index.ts | 40 +---------- packages/opencode/src/share/session.ts | 67 +++++++++++++++++++ packages/opencode/src/share/share-next.ts | 6 +- 5 files changed, 83 insertions(+), 44 deletions(-) create mode 100644 packages/opencode/src/share/session.ts diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e8f3e6a11e1c..8b693e79ae20 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -21,6 +21,7 @@ import { cmd } from "./cmd" import { ModelsDev } from "../../provider/models" import { Instance } from "@/project/instance" import { bootstrap } from "../bootstrap" +import { SessionShare } from "@/share/session" import { Session } from "../../session" import type { SessionID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema" @@ -559,7 +560,7 @@ export const GithubRunCommand = cmd({ shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return - await Session.share(session.id) + await SessionShare.share(session.id) return session.id.slice(-8) })() console.log("opencode session", session.id) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index b1a6af582728..83658987e3c7 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -9,6 +9,7 @@ import { SessionPrompt } from "../../session/prompt" import { SessionRunState } from "@/session/run-state" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" +import { SessionShare } from "@/share/session" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" @@ -206,10 +207,10 @@ export const SessionRoutes = lazy(() => }, }, }), - validator("json", Session.create.schema.optional()), + validator("json", Session.create.schema), async (c) => { const body = c.req.valid("json") ?? {} - const session = await Session.create(body) + const session = await SessionShare.create(body) return c.json(session) }, ) @@ -426,7 +427,7 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - await Session.share(sessionID) + await SessionShare.share(sessionID) const session = await Session.get(sessionID) return c.json(session) }, @@ -491,12 +492,12 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.unshare.schema, + sessionID: SessionID.zod, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID - await Session.unshare(sessionID) + await SessionShare.unshare(sessionID) const session = await Session.get(sessionID) return c.json(session) }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 2e68f22ede93..bbd6693c53ac 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -5,7 +5,6 @@ import { Bus } from "@/bus" import { Decimal } from "decimal.js" import z from "zod" import { type ProviderMetadata } from "ai" -import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Installation } from "../installation" @@ -30,7 +29,7 @@ import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" -import { Effect, Layer, Scope, ServiceMap } from "effect" +import { Effect, Layer, ServiceMap } from "effect" import { makeRuntime } from "@/effect/run-service" export namespace Session { @@ -319,8 +318,6 @@ export namespace Session { readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect readonly get: (id: SessionID) => Effect.Effect - readonly share: (id: SessionID) => Effect.Effect<{ url: string }> - readonly unshare: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect @@ -364,12 +361,10 @@ export namespace Session { const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) - export const layer: Layer.Layer = Layer.effect( + export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service - const config = yield* Config.Service - const scope = yield* Scope.Scope const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -399,11 +394,6 @@ export namespace Session { yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) - const cfg = yield* config.get() - if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) { - yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) - } - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { // This only exist for backwards compatibility. We should not be // manually publishing this event; it is a sync event now @@ -422,25 +412,6 @@ export namespace Session { return fromRow(row) }) - const share = Effect.fn("Session.share")(function* (id: SessionID) { - const cfg = yield* config.get() - if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration") - const result = yield* Effect.promise(async () => { - const { ShareNext } = await import("@/share/share-next") - return ShareNext.create(id) - }) - yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } })) - return result - }) - - const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) { - yield* Effect.promise(async () => { - const { ShareNext } = await import("@/share/share-next") - await ShareNext.remove(id) - }) - yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })) - }) - const children = Effect.fn("Session.children")(function* (parentID: SessionID) { const ctx = yield* InstanceState.context const rows = yield* db((d) => @@ -460,7 +431,6 @@ export namespace Session { for (const child of kids) { yield* remove(child.id) } - yield* unshare(sessionID).pipe(Effect.ignore) yield* Effect.sync(() => { SyncEvent.run(Event.Deleted, { sessionID, info: session }) SyncEvent.remove(sessionID) @@ -661,8 +631,6 @@ export namespace Session { fork, touch, get, - share, - unshare, setTitle, setArchived, setPermission, @@ -683,7 +651,7 @@ export namespace Session { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) const { runPromise } = makeRuntime(Service, defaultLayer) @@ -704,8 +672,6 @@ export namespace Session { ) export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id))) - export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id))) - export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id))) export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) => runPromise((svc) => svc.setTitle(input)), diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts new file mode 100644 index 000000000000..1446b5bb4def --- /dev/null +++ b/packages/opencode/src/share/session.ts @@ -0,0 +1,67 @@ +import { makeRuntime } from "@/effect/run-service" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { SyncEvent } from "@/sync" +import { fn } from "@/util/fn" +import { Effect, Layer, Scope, ServiceMap } from "effect" +import { Config } from "../config/config" +import { Flag } from "../flag/flag" +import { ShareNext } from "./share-next" + +export namespace SessionShare { + export interface Interface { + readonly create: (input?: Parameters[0]) => Effect.Effect + readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown> + readonly unshare: (sessionID: SessionID) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/SessionShare") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const session = yield* Session.Service + const shareNext = yield* ShareNext.Service + const scope = yield* Scope.Scope + + const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { + const conf = yield* cfg.get() + if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") + const result = yield* shareNext.create(sessionID) + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }), + ) + return result + }) + + const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { + yield* shareNext.remove(sessionID) + yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) + }) + + const create = Effect.fn("SessionShare.create")(function* (input?: Parameters[0]) { + const result = yield* session.create(input) + if (result.parentID) return result + const conf = yield* cfg.get() + if (!(Flag.OPENCODE_AUTO_SHARE || conf.share === "auto")) return result + yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) + return result + }) + + return Service.of({ create, share, unshare }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(ShareNext.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Config.defaultLayer), + ) + + const { runPromise } = makeRuntime(Service, defaultLayer) + + export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input))) + export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID))) + export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID))) +} diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 0cd0055c85d2..26b2d2570a32 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -159,7 +159,10 @@ export namespace ShareNext { if (disabled) return cache - const watch = (def: D, fn: (evt: { properties: any }) => Effect.Effect) => + const watch = ( + def: D, + fn: (evt: { properties: any }) => Effect.Effect, + ) => bus.subscribe(def as never).pipe( Stream.runForEach((evt) => fn(evt).pipe( @@ -194,6 +197,7 @@ export namespace ShareNext { yield* watch(Session.Event.Diff, (evt) => sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), ) + yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID)) return cache }), From 51b04ff200918db48756132e172a12aff85d96e4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 16:44:50 -0400 Subject: [PATCH 2/2] docs(v2): note session init route removal --- specs/v2/session.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 specs/v2/session.md diff --git a/specs/v2/session.md b/specs/v2/session.md new file mode 100644 index 000000000000..cae90ba7c883 --- /dev/null +++ b/specs/v2/session.md @@ -0,0 +1,17 @@ +# Session API + +## Remove Dedicated `session.init` Route + +The dedicated `POST /session/:sessionID/init` endpoint exists only as a compatibility wrapper around the normal `/init` command flow. + +Current behavior: + +- the route calls `SessionPrompt.command(...)` +- it sends `Command.Default.INIT` +- it does not provide distinct session-core behavior beyond running the existing init command in an existing session + +V2 plan: + +- remove the dedicated `session.init` endpoint +- rely on the normal `/init` command flow instead +- avoid reintroducing `Session.initialize`-style special cases in the session service layer