diff --git a/github/action.yml b/github/action.yml index 3d983a160995..2ed8fd6f976e 100644 --- a/github/action.yml +++ b/github/action.yml @@ -14,7 +14,7 @@ inputs: required: false share: - description: "Share the opencode session (defaults to true for public repos)" + description: "Deprecated and ignored: session sharing was discontinued" required: false prompt: @@ -71,7 +71,6 @@ runs: env: MODEL: ${{ inputs.model }} AGENT: ${{ inputs.agent }} - SHARE: ${{ inputs.share }} PROMPT: ${{ inputs.prompt }} USE_GITHUB_TOKEN: ${{ inputs.use_github_token }} MENTIONS: ${{ inputs.mentions }} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index e0838825570e..e68e9a369e10 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -299,7 +299,8 @@ export function MessageTimeline(props: { const titleValue = createMemo(() => info()?.title) const titleLabel = createMemo(() => sessionTitle(titleValue())) const shareUrl = createMemo(() => info()?.share?.url) - const shareEnabled = createMemo(() => sync().data.config.share !== "disabled") + // Session sharing was discontinued; keep the share menu and popover hidden. + const shareEnabled = () => false const parentID = createMemo(() => info()?.parentID) const parent = createMemo(() => { const id = parentID() diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 18d5b172ff17..81868961fbba 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -147,93 +147,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory) return permission.isAutoAcceptingDirectory(sdk().directory) } - const write = async (value: string) => { - const body = typeof document === "undefined" ? undefined : document.body - if (body) { - const textarea = document.createElement("textarea") - textarea.value = value - textarea.setAttribute("readonly", "") - textarea.style.position = "fixed" - textarea.style.opacity = "0" - textarea.style.pointerEvents = "none" - body.appendChild(textarea) - textarea.select() - const copied = document.execCommand("copy") - body.removeChild(textarea) - if (copied) return true - } - - const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard - if (!clipboard?.writeText) return false - return clipboard.writeText(value).then( - () => true, - () => false, - ) - } - - const copyShare = async (url: string, existing: boolean) => { - if (!(await write(url))) { - showToast({ - title: language.t("toast.session.share.copyFailed.title"), - variant: "error", - }) - return - } - - showToast({ - title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), - variant: "success", - }) - } - - const share = async () => { - const sessionID = params.id - if (!sessionID) return - - const existing = info()?.share?.url - if (existing) { - await copyShare(existing, true) - return - } - - const url = await sdk() - .client.session.share({ sessionID }) - .then((res) => res.data?.share?.url) - .catch(() => undefined) - if (!url) { - showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), - variant: "error", - }) - return - } - - await copyShare(url, false) - } - - const unshare = async () => { - const sessionID = params.id - if (!sessionID) return - - await sdk() - .client.session.unshare({ sessionID }) - .then(() => - showToast({ - title: language.t("toast.session.unshare.success.title"), - description: language.t("toast.session.unshare.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: language.t("toast.session.unshare.failed.title"), - description: language.t("toast.session.unshare.failed.description"), - variant: "error", - }), - ) - } const exportSession = async () => { const sessionID = params.id @@ -421,30 +334,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { ) } - const shareCmds = () => { - if (sync().data.config.share === "disabled") return [] - return [ - sessionCommand({ - id: "session.share", - title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"), - description: info()?.share?.url - ? language.t("toast.session.share.success.description") - : language.t("command.session.share.description"), - slash: "share", - disabled: !params.id, - onSelect: share, - }), - sessionCommand({ - id: "session.unshare", - title: language.t("command.session.unshare"), - description: language.t("command.session.unshare.description"), - slash: "unshare", - disabled: !params.id || !info()?.share?.url, - onSelect: unshare, - }), - ] - } - const sessionCmds = () => [ sessionCommand({ id: "session.new", @@ -645,7 +534,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { command.register("session", () => [ ...sessionCmds(), - ...shareCmds(), ...fileCmds(), ...contextCmds(), ...viewCmds(), diff --git a/packages/opencode/src/cli/cmd/github.handler.ts b/packages/opencode/src/cli/cmd/github.handler.ts index fcf44279ce7f..e990a3c1fe33 100644 --- a/packages/opencode/src/cli/cmd/github.handler.ts +++ b/packages/opencode/src/cli/cmd/github.handler.ts @@ -19,7 +19,6 @@ import type { import { UI } from "../ui" import { ModelsDev } from "@opencode-ai/core/models-dev" import { InstanceRef } from "@/effect/instance-ref" -import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema" @@ -380,7 +379,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: if (!ctx) return yield* Effect.die("InstanceRef not provided") const gitSvc = yield* Git.Service const sessionSvc = yield* Session.Service - const sessionShare = yield* SessionShare.Service const sessionPrompt = yield* SessionPrompt.Service const events = yield* EventV2Bridge.Service const runLocalEffect = (effect: Effect.Effect) => @@ -407,7 +405,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: const { providerID, modelID } = normalizeModel() const variant = process.env["VARIANT"] || undefined const runId = normalizeRunId() - const share = normalizeShare() const oidcBaseUrl = normalizeOidcBaseUrl() const { owner, repo } = context.repo // For repo events (schedule, workflow_dispatch), payload has no issue/comment data @@ -428,14 +425,12 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: ? (payload as IssueCommentEvent | IssuesEvent).issue.number : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number const runUrl = `/${owner}/${repo}/actions/runs/${runId}` - const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" let appToken: string let octoRest: Octokit let octoGraph: typeof graphql let gitConfig: string let session: { id: SessionID; title: string; version: string } - let shareId: string | undefined let exitCode = 0 let githubClientReady = false type PromptFiles = Awaited>["promptFiles"] @@ -512,12 +507,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: }), ) await subscribeSessionEvents() - shareId = await (async () => { - if (share === false) return - if (!share && repoData.data.private) return - await runLocalEffect(sessionShare.share(session.id)) - return session.id.slice(-8) - })() console.log("opencode session", session.id) // Handle event types: @@ -547,7 +536,7 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: repoData.data.default_branch, branch, summary, - `${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`, + `${response}\n\nTriggered by ${triggerType}${footer()}`, ) if (pr) { console.log(`Created PR #${pr}`) @@ -576,8 +565,7 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: const summary = await summarize(response) await pushToLocalBranch(summary, uncommittedChanges) } - const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`)) - await createComment(`${response}${footer({ image: !hasShared })}`) + await createComment(`${response}${footer()}`) await removeReaction(commentType) } // Fork PR @@ -594,8 +582,7 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: const summary = await summarize(response) await pushToForkBranch(summary, prData, uncommittedChanges) } - const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`)) - await createComment(`${response}${footer({ image: !hasShared })}`) + await createComment(`${response}${footer()}`) await removeReaction(commentType) } } @@ -610,7 +597,7 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: if (switched) { // Agent switched branches (likely created its own branch/PR). // Don't push the stale infrastructure branch — just comment. - await createComment(`${response}${footer({ image: true })}`) + await createComment(`${response}${footer()}`) await removeReaction(commentType) } else if (dirty) { const summary = await summarize(response) @@ -619,16 +606,16 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: repoData.data.default_branch, branch, summary, - `${response}\n\nCloses #${issueId}${footer({ image: true })}`, + `${response}\n\nCloses #${issueId}${footer()}`, ) if (pr) { - await createComment(`Created PR #${pr}${footer({ image: true })}`) + await createComment(`Created PR #${pr}${footer()}`) } else { - await createComment(`${response}${footer({ image: true })}`) + await createComment(`${response}${footer()}`) } await removeReaction(commentType) } else { - await createComment(`${response}${footer({ image: true })}`) + await createComment(`${response}${footer()}`) await removeReaction(commentType) } } @@ -677,14 +664,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: return value } - function normalizeShare() { - const value = process.env["SHARE"] - if (!value) return undefined - if (value === "true") return true - if (value === "false") return false - throw new Error(`Invalid share value: ${value}. Share must be a boolean.`) - } - function normalizeUseGithubToken() { const value = process.env["USE_GITHUB_TOKEN"] if (!value) return false @@ -1351,18 +1330,8 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: } } - function footer(opts?: { image?: boolean }) { - const image = (() => { - if (!shareId) return "" - if (!opts?.image) return "" - - const titleAlt = encodeURIComponent(session.title.substring(0, 50)) - const title64 = Buffer.from(session.title.substring(0, 700), "utf8").toString("base64") - - return `${titleAlt}\n` - })() - const shareUrl = shareId ? `[opencode session](${shareBaseUrl}/s/${shareId})  |  ` : "" - return `\n\n${image}${shareUrl}[github run](${runUrl})` + function footer() { + return `\n\n[github run](${runUrl})` } async function fetchRepo() { diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 1b7350f74478..ace560368dd1 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -2,11 +2,10 @@ import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" -import { CliError, effectCmd } from "../effect-cmd" +import { CliError, effectCmd, fail } from "../effect-cmd" import { Database } from "@opencode-ai/core/database/database" import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" import { InstanceRef } from "@/effect/instance-ref" -import { ShareNext } from "@/share/share-next" import { EOL } from "os" import path from "path" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -16,28 +15,6 @@ import type { InstanceContext } from "@/project/instance-context" const decodeMessageInfo = Schema.decodeUnknownSync(SessionV1.Info) const decodePart = Schema.decodeUnknownSync(SessionV1.Part) -/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ -export type ShareData = - | { type: "session"; data: SDKSession } - | { type: "message"; data: Message } - | { type: "part"; data: Part } - | { type: "session_diff"; data: unknown } - | { type: "model"; data: unknown } - -/** Extract share ID from a share URL like https://opncd.ai/share/abc123 */ -export function parseShareUrl(url: string): string | null { - const match = url.match(/^https?:\/\/[^/]+\/share\/([a-zA-Z0-9_-]+)$/) - return match ? match[1] : null -} - -export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: string): boolean { - try { - return new URL(shareUrl).origin === new URL(accountBaseUrl).origin - } catch { - return false - } -} - export function formatImportFileError(file: string, error: FSUtil.Error) { if (error._tag === "PlatformError") { if (error.reason._tag === "NotFound") return `File not found: ${file}` @@ -49,54 +26,14 @@ export function formatImportFileError(file: string, error: FSUtil.Error) { return `Invalid JSON in ${file}: ${detail}` } -/** - * Transform ShareNext API response (flat array) into the nested structure for local file storage. - * - * The API returns a flat array: [session, message, message, part, part, ...] - * Local storage expects: { info: session, messages: [{ info: message, parts: [part, ...] }, ...] } - * - * This groups parts by their messageID to reconstruct the hierarchy before writing to disk. - */ -export function transformShareData(shareData: ShareData[]): { - info: SDKSession - messages: Array<{ info: Message; parts: Part[] }> -} | null { - const sessionItem = shareData.find((d) => d.type === "session") - if (!sessionItem) return null - - const messageMap = new Map() - const partMap = new Map() - - for (const item of shareData) { - if (item.type === "message") { - messageMap.set(item.data.id, item.data) - } else if (item.type === "part") { - if (!partMap.has(item.data.messageID)) { - partMap.set(item.data.messageID, []) - } - partMap.get(item.data.messageID)!.push(item.data) - } - } - - if (messageMap.size === 0) return null - - return { - info: sessionItem.data, - messages: Array.from(messageMap.values()).map((msg) => ({ - info: msg, - parts: partMap.get(msg.id) ?? [], - })), - } -} - type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> } export const ImportCommand = effectCmd({ command: "import ", - describe: "import session data from JSON file or URL", + describe: "import session data from JSON file", builder: (yargs) => yargs.positional("file", { - describe: "path to JSON file or share URL", + describe: "path to JSON file", type: "string", demandOption: true, }), @@ -108,67 +45,15 @@ export const ImportCommand = effectCmd({ }) const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) { - const share = yield* ShareNext.Service const fs = yield* FSUtil.Service const { db } = yield* Database.Service - let exportData: ExportData | undefined - - const isUrl = file.startsWith("http://") || file.startsWith("https://") + if (file.startsWith("http://") || file.startsWith("https://")) + return yield* fail("Importing from share URLs is no longer supported: session sharing has been discontinued") - if (isUrl) { - const slug = parseShareUrl(file) - if (!slug) { - const baseUrl = yield* Effect.orDie(share.url()) - process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) - process.stdout.write(EOL) - return - } - - const baseUrl = new URL(file).origin - const req = yield* Effect.orDie(share.request()) - const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {} - - const tryFetch = (url: string) => - Effect.tryPromise({ - try: () => fetch(url, { headers }), - catch: (e) => - new CliError({ - message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`, - }), - }) - - const dataPath = req.api.data(slug) - let response = yield* tryFetch(`${baseUrl}${dataPath}`) - - if (!response.ok && dataPath !== `/api/share/${slug}/data`) { - response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`) - } - - if (!response.ok) { - process.stdout.write(`Failed to fetch share data: ${response.statusText}`) - process.stdout.write(EOL) - return - } - - const shareData = yield* Effect.tryPromise({ - try: () => response.json() as Promise, - catch: () => new CliError({ message: "Share data was not valid JSON" }), - }) - const transformed = transformShareData(shareData) - - if (!transformed) { - process.stdout.write(`Share not found or empty: ${slug}`) - process.stdout.write(EOL) - return - } - - exportData = transformed - } else { - exportData = (yield* fs - .readJson(file) - .pipe(Effect.mapError((error) => new CliError({ message: formatImportFileError(file, error) })))) as ExportData - } + const exportData = (yield* fs + .readJson(file) + .pipe(Effect.mapError((error) => new CliError({ message: formatImportFileError(file, error) })))) as ExportData if (!exportData) { process.stdout.write(`Failed to read session data`) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 420972235746..6bab3286c5b9 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -43,14 +43,12 @@ export const PrCommand = effectCmd({ "view", `${prNumber}`, "--json", - "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", + "headRepository,headRepositoryOwner,isCrossRepository,headRefName", ], { nothrow: true }, ), ) - let sessionId: string | undefined - if (prInfoResult.code === 0 && prInfoResult.text.trim()) { const prInfo = JSON.parse(prInfoResult.text) @@ -71,26 +69,6 @@ export const PrCommand = effectCmd({ cwd: worktree, }) } - - if (prInfo?.body) { - const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) - if (sessionMatch) { - const sessionUrl = sessionMatch[0] - UI.println(`Found opencode session: ${sessionUrl}`) - UI.println(`Importing session...`) - - const importResult = yield* Effect.promise(() => - Process.text(["opencode", "import", sessionUrl], { nothrow: true }), - ) - if (importResult.code === 0) { - const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/) - if (sessionIdMatch) { - sessionId = sessionIdMatch[1] - UI.println(`Session imported: ${sessionId}`) - } - } - } - } } UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) @@ -98,10 +76,9 @@ export const PrCommand = effectCmd({ UI.println("Starting opencode...") UI.println() - const opencodeArgs = sessionId ? ["-s", sessionId] : [] const code = yield* Effect.promise( () => - Process.spawn(["opencode", ...opencodeArgs], { + Process.spawn(["opencode"], { stdin: "inherit", stdout: "inherit", stderr: "inherit", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index cccc2550179f..944aec17abd7 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -160,7 +160,8 @@ export const RunCommand = effectCmd({ }) .option("share", { type: "boolean", - describe: "share the session", + describe: "deprecated: session sharing was discontinued", + hidden: true, }) .option("model", { type: "string", @@ -532,19 +533,8 @@ export const RunCommand = effectCmd({ } } - async function share(sdk: OpencodeClient, sessionID: string) { - const cfg = await sdk.config.get() - if (!cfg.data) return - if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return - const res = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!res.error && "data" in res && res.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) - } + async function share(_sdk: OpencodeClient, _sessionID: string) { + if (args.share) UI.println(UI.Style.TEXT_DANGER_BOLD + "! Session sharing has been discontinued") } async function createFreshSession( diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index d17326966f92..d010eb8039ca 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -44,7 +44,6 @@ import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Installation } from "@/installation" -import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" @@ -103,7 +102,6 @@ export const AppLayer = AppNodeBuilderV1.build( Workspace.node, Worktree.node, Installation.node, - ShareNext.node, SessionShare.node, ]), ).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index 57fe43b46ee3..f9902bc025e4 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -5,7 +5,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Plugin } from "@/plugin" import { LSP } from "@/lsp/lsp" import { Format } from "@/format" -import { ShareNext } from "@/share/share-next" import { Vcs } from "@/project/vcs" import { Snapshot } from "@/snapshot" import { Config } from "@/config/config" @@ -13,7 +12,7 @@ import * as Observability from "@opencode-ai/core/observability" import { memoMap } from "@opencode-ai/core/effect/memo-map" export const BootstrapLayer = AppNodeBuilder.build( - LayerNode.group([Config.node, Plugin.node, ShareNext.node, Format.node, LSP.node, Vcs.node, Snapshot.node]), + LayerNode.group([Config.node, Plugin.node, Format.node, LSP.node, Vcs.node, Snapshot.node]), ).pipe(Layer.provide(Observability.layer)) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index acf0caed3cdf..d11e4e8e6204 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -6,7 +6,6 @@ import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" import { InstanceState } from "@/effect/instance-state" -import { ShareNext } from "@/share/share-next" import { Effect, Layer } from "effect" import { Config } from "@/config/config" import { Service } from "./bootstrap-service" @@ -25,7 +24,6 @@ const layer = Layer.effect( const lsp = yield* LSP.Service const plugin = yield* Plugin.Service const project = yield* Project.Service - const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -39,7 +37,7 @@ const layer = Layer.effect( // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( - [lsp, shareNext, format, vcs, snapshot, project], + [lsp, format, vcs, snapshot, project], (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) @@ -52,7 +50,7 @@ const layer = Layer.effect( export const node = makeGlobalNode({ service: Service, layer: layer, - deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, ShareNext.node, Snapshot.node, Vcs.node], + deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, Snapshot.node, Vcs.node], }) export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fb9d2db65621..94555ca27806 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -40,7 +40,6 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" -import { ShareNext } from "@/share/share-next" import { Skill } from "@/skill" import { Discovery } from "@/skill/discovery" import { Snapshot } from "@/snapshot" @@ -258,7 +257,6 @@ const app = LayerNode.group([ Workspace.node, Worktree.node, Installation.node, - ShareNext.node, SessionShare.node, InstanceStore.node, httpClient, diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index a4ef77fbfd2f..7634aea18e43 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -1,10 +1,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" -import { Effect, Layer, Scope, Context } from "effect" -import { Config } from "@/config/config" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { ShareNext } from "./share-next" +import { Effect, Layer, Context } from "effect" export interface Interface { readonly create: (input?: Session.CreateInput) => Effect.Effect @@ -17,32 +14,20 @@ export class Service extends Context.Service()("@opencode/Se 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 flags = yield* RuntimeFlags.Service - - 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* session.setShare({ sessionID, share: { url: result.url } }) - return result + + // Session sharing was discontinued. The share endpoints stay for API compatibility: share fails and unshare only + // clears the local link. + const share = Effect.fn("SessionShare.share")(function* (_sessionID: SessionID) { + return yield* Effect.fail(new Error("Session sharing has been discontinued")) }) const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { - yield* shareNext.remove(sessionID) yield* session.setShare({ sessionID, share: undefined }) }) const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) { - const result = yield* session.create(input) - if (result.parentID) return result - const conf = yield* cfg.get() - if (!(flags.autoShare || conf.share === "auto")) return result - yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) - return result + return yield* session.create(input) }) return Service.of({ create, share, unshare }) @@ -52,7 +37,7 @@ const layer = Layer.effect( export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Config.node, Session.node, ShareNext.node, RuntimeFlags.node], + deps: [Session.node], }) export * as SessionShare from "./session" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts deleted file mode 100644 index 60112e10d964..000000000000 --- a/packages/opencode/src/share/share-next.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/app-node-platform" -import type * as SDK from "@opencode-ai/sdk/v2" -import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { Account } from "@/account/account" -import { EventV2Bridge } from "@/event-v2-bridge" -import { InstanceState } from "@/effect/instance-state" -import { Provider } from "@/provider/provider" - -import { Session } from "@/session/session" -import { MessageV2 } from "@/session/message-v2" -import type { SessionID } from "@/session/schema" -import { Database } from "@opencode-ai/core/database/database" -import { eq } from "drizzle-orm" -import { Config } from "@/config/config" -import { SessionShareTable } from "@opencode-ai/core/share/sql" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { ModelV2 } from "@opencode-ai/core/model" -import { EventV2 } from "@opencode-ai/core/event" - -const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" - -export type Api = { - create: string - sync: (shareID: string) => string - remove: (shareID: string) => string - data: (shareID: string) => string -} - -export type Req = { - headers: Record - api: Api - baseUrl: string -} - -const ShareSchema = Schema.Struct({ - id: Schema.String, - url: Schema.String, - secret: Schema.String, -}) -export type Share = typeof ShareSchema.Type - -type State = { - queue: Map> - scope: Scope.Closeable - shared: Map -} - -type Data = - | { - type: "session" - data: SDK.Session - } - | { - type: "message" - data: SDK.Message - } - | { - type: "part" - data: SDK.Part - } - | { - type: "session_diff" - data: SDK.SnapshotFileDiff[] - } - | { - type: "model" - data: SDK.Model[] - } - -export interface Interface { - readonly init: () => Effect.Effect - readonly url: () => Effect.Effect - readonly request: () => Effect.Effect - readonly create: (sessionID: SessionID) => Effect.Effect - readonly remove: (sessionID: SessionID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/ShareNext") {} - -export const use = serviceUse(Service) - -function api(resource: string): Api { - return { - create: `/api/${resource}`, - sync: (shareID) => `/api/${resource}/${shareID}/sync`, - remove: (shareID) => `/api/${resource}/${shareID}`, - data: (shareID) => `/api/${resource}/${shareID}/data`, - } -} - -const legacyApi = api("share") -const consoleApi = api("shares") - -function key(item: Data) { - switch (item.type) { - case "session": - return "session" - case "message": - return `message/${item.data.id}` - case "part": - return `part/${item.data.messageID}/${item.data.id}` - case "session_diff": - return "session_diff" - case "model": - return "model" - } -} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const account = yield* Account.Service - const events = yield* EventV2Bridge.Service - const cfg = yield* Config.Service - const { db } = yield* Database.Service - const http = yield* HttpClient.HttpClient - const httpOk = HttpClient.filterStatusOk(http) - const provider = yield* Provider.Service - const session = yield* Session.Service - - function sync(sessionID: SessionID, data: Data[]) { - return Effect.gen(function* () { - if (disabled) return - const share = yield* getCached(sessionID) - if (!share) return - - const s = yield* InstanceState.get(state) - const existing = s.queue.get(sessionID) - if (existing) { - for (const item of data) { - existing.set(key(item), item) - } - return - } - - const next = new Map(data.map((item) => [key(item), item])) - s.queue.set(sessionID, next) - yield* flush(sessionID).pipe( - Effect.delay(1000), - Effect.catchCause((cause) => Effect.logError("share flush failed", { sessionID: sessionID, cause: cause })), - Effect.forkIn(s.scope), - ) - }) - } - - const state: InstanceState.InstanceState = yield* InstanceState.make( - Effect.fn("ShareNext.state")(function* (_ctx) { - const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() } - - yield* Effect.addFinalizer(() => - Scope.close(cache.scope, Exit.void).pipe( - Effect.andThen( - Effect.sync(() => { - cache.queue.clear() - cache.shared.clear() - }), - ), - ), - ) - - if (disabled) return cache - - const watch = ( - def: D, - fn: (data: EventV2.Data) => Effect.Effect, - ) => - events.listen((event) => { - if (event.type !== def.type || event.location?.directory !== _ctx.directory) return Effect.void - return fn(event.data as EventV2.Data).pipe( - Effect.catchCause((cause) => - Effect.logError("share subscriber failed", { type: def.type, cause: cause }), - ), - ) - }) - - yield* watch(Session.Event.Updated, (data) => - Effect.gen(function* () { - const info = data.info - yield* sync(info.id, [{ type: "session", data: structuredClone(info) as SDK.Session }]) - }), - ) - yield* watch(MessageV2.Event.Updated, (data) => - Effect.gen(function* () { - const info = data.info - yield* sync(info.sessionID, [{ type: "message", data: structuredClone(info) as SDK.Message }]) - if (info.role !== "user") return - const model = yield* provider.getModel(info.model.providerID, info.model.modelID) - yield* sync(info.sessionID, [{ type: "model", data: [model] }]) - }), - ) - yield* watch(MessageV2.Event.PartUpdated, (data) => - sync(data.part.sessionID, [{ type: "part", data: structuredClone(data.part) as SDK.Part }]), - ) - yield* watch(Session.Event.Diff, (data) => - sync(data.sessionID, [{ type: "session_diff", data: structuredClone(data.diff) as SDK.SnapshotFileDiff[] }]), - ) - yield* watch(Session.Event.Deleted, (data) => remove(data.sessionID)) - - return cache - }), - ) - - const request = Effect.fn("ShareNext.request")(function* () { - const headers: Record = {} - const active = yield* account.active() - if (Option.isNone(active) || !active.value.active_org_id) { - const baseUrl = (yield* cfg.get()).enterprise?.url ?? "https://opncd.ai" - return { headers, api: legacyApi, baseUrl } satisfies Req - } - - const token = yield* account.token(active.value.id) - if (Option.isNone(token)) { - throw new Error("No active account token available for sharing") - } - - headers.authorization = `Bearer ${token.value}` - headers["x-org-id"] = active.value.active_org_id - return { headers, api: consoleApi, baseUrl: active.value.url } satisfies Req - }) - - const get = Effect.fnUntraced(function* (sessionID: SessionID) { - const row = yield* db - .select() - .from(SessionShareTable) - .where(eq(SessionShareTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!row) return - return { id: row.id, secret: row.secret, url: row.url } satisfies Share - }) - - const getCached = Effect.fnUntraced(function* (sessionID: SessionID) { - const s = yield* InstanceState.get(state) - if (s.shared.has(sessionID)) { - const cached = s.shared.get(sessionID) - return cached === null ? undefined : cached - } - - const share = yield* get(sessionID) - s.shared.set(sessionID, share ?? null) - return share - }) - - const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) { - if (disabled) return - const s = yield* InstanceState.get(state) - const queued = s.queue.get(sessionID) - if (!queued) return - - s.queue.delete(sessionID) - - const share = yield* getCached(sessionID) - if (!share) return - - const req = yield* request() - const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe( - HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }), - Effect.flatMap((r) => http.execute(r)), - ) - - if (res.status >= 400) { - yield* Effect.logWarning("failed to sync share", { - sessionID: sessionID, - shareID: share.id, - status: res.status, - }) - } - }) - - const full = Effect.fn("ShareNext.full")(function* (sessionID: SessionID) { - yield* Effect.logInfo("full sync", { sessionID: sessionID }) - const info = yield* session.get(sessionID) - const diffs = yield* session.diff(sessionID) - const messages = yield* session.messages({ sessionID }) - const models = yield* Effect.forEach( - Array.from( - new Map( - messages - .filter((msg) => msg.info.role === "user") - .map((msg) => (msg.info as SDK.UserMessage).model) - .map((item) => [`${item.providerID}/${item.modelID}`, item] as const), - ).values(), - ), - (item) => provider.getModel(ProviderV2.ID.make(item.providerID), ModelV2.ID.make(item.modelID)), - { concurrency: 8 }, - ) - - yield* sync(sessionID, [ - { type: "session", data: info }, - ...messages.map((item) => ({ type: "message" as const, data: item.info })), - ...messages.flatMap((item) => item.parts.map((part) => ({ type: "part" as const, data: part }))), - { type: "session_diff", data: diffs }, - { type: "model", data: models }, - ]) - }) - - const init = Effect.fn("ShareNext.init")(function* () { - if (disabled) return - yield* InstanceState.get(state) - }) - - const url = Effect.fn("ShareNext.url")(function* () { - return (yield* request()).baseUrl - }) - - const create = Effect.fn("ShareNext.create")(function* (sessionID: SessionID) { - if (disabled) return { id: "", url: "", secret: "" } - yield* Effect.logInfo("creating share", { sessionID: sessionID }) - const req = yield* request() - const result = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.create}`).pipe( - HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ sessionID }), - Effect.flatMap((r) => httpOk.execute(r)), - Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)), - ) - yield* db - .insert(SessionShareTable) - .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url }) - .onConflictDoUpdate({ - target: SessionShareTable.session_id, - set: { id: result.id, secret: result.secret, url: result.url }, - }) - .run() - .pipe(Effect.orDie) - const s = yield* InstanceState.get(state) - s.shared.set(sessionID, result) - yield* full(sessionID).pipe( - Effect.catchCause((cause) => Effect.logError("share full sync failed", { sessionID: sessionID, cause: cause })), - Effect.forkIn(s.scope), - ) - return result - }) - - const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) { - if (disabled) return - yield* Effect.logInfo("removing share", { sessionID: sessionID }) - const s = yield* InstanceState.get(state) - const share = yield* getCached(sessionID) - if (!share) { - s.shared.delete(sessionID) - s.queue.delete(sessionID) - return - } - - const req = yield* request() - yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( - HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ secret: share.secret }), - Effect.flatMap((r) => httpOk.execute(r)), - ) - - yield* db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run().pipe(Effect.orDie) - s.shared.delete(sessionID) - s.queue.delete(sessionID) - }) - - return Service.of({ init, url, request, create, remove }) - }), -) - -export const node = LayerNode.make({ - service: Service, - layer: layer, - deps: [Account.node, EventV2Bridge.node, Config.node, Database.node, httpClient, Provider.node, Session.node], -}) - -export * as ShareNext from "./share-next" diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index d22ded5af10f..f9d9ea70ba68 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -85,7 +85,6 @@ Options: -c, --continue continue the last session [boolean] -s, --session session id to continue [string] --fork fork the session before continuing (requires --continue or --session) [boolean] - --share share the session [boolean] -m, --model model to use in the format of provider/model [string] --agent agent to use [string] --format format: default (formatted) or json (raw JSON events) @@ -302,10 +301,10 @@ Options: exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode import --help 1`] = ` "opencode import -import session data from JSON file or URL +import session data from JSON file Positionals: - file path to JSON file or share URL [string] [required] + file path to JSON file [string] [required] Options: -h, --help show help [boolean] diff --git a/packages/opencode/test/cli/import.test.ts b/packages/opencode/test/cli/import.test.ts index 59824c3efcb9..f3abdd5618a9 100644 --- a/packages/opencode/test/cli/import.test.ts +++ b/packages/opencode/test/cli/import.test.ts @@ -1,11 +1,5 @@ import { test, expect } from "bun:test" -import { - formatImportFileError, - parseShareUrl, - shouldAttachShareAuthHeaders, - transformShareData, - type ShareData, -} from "../../src/cli/cmd/import" +import { formatImportFileError } from "../../src/cli/cmd/import" import { FSUtil } from "@opencode-ai/core/fs-util" import { PlatformError } from "effect" @@ -41,50 +35,3 @@ test("formats import file errors", () => { ), ).toBe("Invalid JSON in test.json: Unexpected token") }) - -// parseShareUrl tests -test("parses valid share URLs", () => { - expect(parseShareUrl("https://opncd.ai/share/Jsj3hNIW")).toBe("Jsj3hNIW") - expect(parseShareUrl("https://custom.example.com/share/abc123")).toBe("abc123") - expect(parseShareUrl("http://localhost:3000/share/test_id-123")).toBe("test_id-123") -}) - -test("rejects invalid URLs", () => { - expect(parseShareUrl("https://opncd.ai/s/Jsj3hNIW")).toBeNull() // legacy format - expect(parseShareUrl("https://opncd.ai/share/")).toBeNull() - expect(parseShareUrl("https://opncd.ai/share/id/extra")).toBeNull() - expect(parseShareUrl("not-a-url")).toBeNull() -}) - -test("only attaches share auth headers for same-origin URLs", () => { - expect(shouldAttachShareAuthHeaders("https://control.example.com/share/abc", "https://control.example.com")).toBe( - true, - ) - expect(shouldAttachShareAuthHeaders("https://other.example.com/share/abc", "https://control.example.com")).toBe(false) - expect(shouldAttachShareAuthHeaders("https://control.example.com:443/share/abc", "https://control.example.com")).toBe( - true, - ) - expect(shouldAttachShareAuthHeaders("not-a-url", "https://control.example.com")).toBe(false) -}) - -// transformShareData tests -test("transforms share data to storage format", () => { - const data: ShareData[] = [ - { type: "session", data: { id: "sess-1", title: "Test" } as any }, - { type: "message", data: { id: "msg-1", sessionID: "sess-1" } as any }, - { type: "part", data: { id: "part-1", messageID: "msg-1" } as any }, - { type: "part", data: { id: "part-2", messageID: "msg-1" } as any }, - ] - - const result = transformShareData(data)! - - expect(result.info.id).toBe("sess-1") - expect(result.messages).toHaveLength(1) - expect(result.messages[0].parts).toHaveLength(2) -}) - -test("returns null for invalid share data", () => { - expect(transformShareData([])).toBeNull() - expect(transformShareData([{ type: "message", data: {} as any }])).toBeNull() - expect(transformShareData([{ type: "session", data: { id: "s" } as any }])).toBeNull() // no messages -}) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index c8e11ea8451b..77641cec3290 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1656,14 +1656,7 @@ const scenarios: Scenario[] = [ .mutating() .seeded((ctx) => ctx.session({ title: "Share session" })) .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "share should return the session") - }, - "status", - ), + .json(500, object, "status"), http.protected .delete("/session/{sessionID}/share", "session.unshare") .mutating() diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts deleted file mode 100644 index fe036be8c959..000000000000 --- a/packages/opencode/test/share/share-next.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { beforeEach, describe, expect } from "bun:test" -import { Effect, Exit, Layer, Option } from "effect" -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/app-node-platform" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { SessionProjector } from "@opencode-ai/core/session/projector" - -import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" -import { AccountRepo } from "../../src/account/repo" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Session } from "@/session/session" -import type { SessionID } from "../../src/session/schema" -import { ShareNext } from "@/share/share-next" -import { SessionShareTable } from "@opencode-ai/core/share/sql" -import { Database } from "@opencode-ai/core/database/database" -import { eq } from "drizzle-orm" -import { provideTmpdirInstance } from "../fixture/fixture" -import { resetDatabase } from "../fixture/db" -import { pollWithTimeout, testEffect } from "../lib/effect" - -const env = LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node])) -const it = testEffect(env) - -const json = (req: Parameters[0], body: unknown, status = 200) => - HttpClientResponse.fromWeb( - req, - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }), - ) - -const none = HttpClient.make(() => Effect.die("unexpected http call")) - -function requestLayer(client: HttpClient.HttpClient) { - const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const - return LayerNode.compile(LayerNode.group([ShareNext.node, AccountRepo.node]), [replacement]) -} - -function integrationLayer(client: HttpClient.HttpClient) { - const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const - return LayerNode.compile( - LayerNode.group([ - ShareNext.node, - EventV2Bridge.node, - Session.node, - SessionProjector.node, - AccountRepo.node, - Database.node, - ]), - [replacement], - ) -} - -const share = (id: SessionID) => - Effect.gen(function* () { - const { db } = yield* Database.Service - return yield* db - .select() - .from(SessionShareTable) - .where(eq(SessionShareTable.session_id, id)) - .get() - .pipe(Effect.orDie) - }) - -const seed = (url: string, org?: string) => - AccountRepo.Service.use((repo) => - repo.persistAccount({ - id: AccountID.make("account-1"), - email: "user@example.com", - url, - accessToken: AccessToken.make("st_test_token"), - refreshToken: RefreshToken.make("rt_test_token"), - expiry: Date.now() + 10 * 60_000, - orgID: org ? Option.some(OrgID.make(org)) : Option.none(), - }), - ) - -beforeEach(async () => { - await resetDatabase() -}) - -describe("ShareNext", () => { - it.live("request uses legacy share API without active org account", () => - provideTmpdirInstance( - () => - ShareNext.Service.use((svc) => - Effect.gen(function* () { - const req = yield* svc.request() - - expect(req.api.create).toBe("/api/share") - expect(req.api.sync("shr_123")).toBe("/api/share/shr_123/sync") - expect(req.api.remove("shr_123")).toBe("/api/share/shr_123") - expect(req.api.data("shr_123")).toBe("/api/share/shr_123/data") - expect(req.baseUrl).toBe("https://legacy-share.example.com") - expect(req.headers).toEqual({}) - }), - ).pipe(Effect.provide(requestLayer(none))), - { config: { enterprise: { url: "https://legacy-share.example.com" } } }, - ), - ) - - it.live("request uses default URL when no enterprise config", () => - provideTmpdirInstance(() => - ShareNext.Service.use((svc) => - Effect.gen(function* () { - const req = yield* svc.request() - - expect(req.baseUrl).toBe("https://opncd.ai") - expect(req.api.create).toBe("/api/share") - expect(req.headers).toEqual({}) - }), - ).pipe(Effect.provide(requestLayer(none))), - ), - ) - - it.live("request uses org share API with auth headers when account is active", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - yield* seed("https://control.example.com", "org-1") - - const req = yield* ShareNext.use.request() - - expect(req.api.create).toBe("/api/shares") - expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync") - expect(req.api.remove("shr_123")).toBe("/api/shares/shr_123") - expect(req.api.data("shr_123")).toBe("/api/shares/shr_123/data") - expect(req.baseUrl).toBe("https://control.example.com") - expect(req.headers).toEqual({ - authorization: "Bearer st_test_token", - "x-org-id": "org-1", - }) - }).pipe(Effect.provide(requestLayer(none))), - ), - ) - - it.live("create posts share, persists it, and returns the result", () => - provideTmpdirInstance( - () => { - const createRequests: HttpClientRequest.HttpClientRequest[] = [] - const client = HttpClient.make((req) => { - if (req.url.endsWith("/api/share")) { - createRequests.push(req) - return Effect.succeed( - json(req, { - id: "shr_abc", - url: "https://legacy-share.example.com/share/abc", - secret: "sec_123", - }), - ) - } - return Effect.succeed(json(req, { ok: true })) - }) - return Effect.gen(function* () { - const session = yield* (yield* Session.Service).create({ title: "test" }) - - const result = yield* (yield* ShareNext.Service).create(session.id) - - expect(result.id).toBe("shr_abc") - expect(result.url).toBe("https://legacy-share.example.com/share/abc") - expect(result.secret).toBe("sec_123") - - const row = yield* share(session.id) - expect(row?.id).toBe("shr_abc") - expect(row?.url).toBe("https://legacy-share.example.com/share/abc") - expect(row?.secret).toBe("sec_123") - - expect(createRequests).toHaveLength(1) - expect(createRequests[0].method).toBe("POST") - expect(createRequests[0].url).toBe("https://legacy-share.example.com/api/share") - }).pipe(Effect.provide(integrationLayer(client))) - }, - { config: { enterprise: { url: "https://legacy-share.example.com" } } }, - ), - ) - - it.live("remove deletes the persisted share and calls the delete endpoint", () => - provideTmpdirInstance( - () => { - const seen: HttpClientRequest.HttpClientRequest[] = [] - const client = HttpClient.make((req) => { - seen.push(req) - if (req.method === "POST") { - return Effect.succeed( - json(req, { - id: "shr_abc", - url: "https://legacy-share.example.com/share/abc", - secret: "sec_123", - }), - ) - } - return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 }))) - }) - return Effect.gen(function* () { - const session = yield* (yield* Session.Service).create({ title: "test" }) - const service = yield* ShareNext.Service - - yield* service.create(session.id) - yield* service.remove(session.id) - - expect(yield* share(session.id)).toBeUndefined() - expect(seen.map((req) => [req.method, req.url])).toEqual([ - ["POST", "https://legacy-share.example.com/api/share"], - ["DELETE", "https://legacy-share.example.com/api/share/shr_abc"], - ]) - }).pipe(Effect.provide(integrationLayer(client))) - }, - { config: { enterprise: { url: "https://legacy-share.example.com" } } }, - ), - ) - - it.live("create fails on a non-ok response and does not persist a share", () => - provideTmpdirInstance(() => { - const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500))) - return Effect.gen(function* () { - const session = yield* (yield* Session.Service).create({ title: "test" }) - - const exit = yield* ShareNext.Service.use((svc) => Effect.exit(svc.create(session.id))) - - expect(Exit.isFailure(exit)).toBe(true) - expect(yield* share(session.id)).toBeUndefined() - }).pipe(Effect.provide(integrationLayer(client))) - }), - ) - - it.live("ShareNext coalesces rapid diff events into one delayed sync with latest data", () => - provideTmpdirInstance( - () => { - const seen: Array<{ url: string; body: string }> = [] - const client = HttpClient.make((req) => { - if (req.url.endsWith("/sync") && req.body._tag === "Uint8Array") { - seen.push({ url: req.url, body: new TextDecoder().decode(req.body.body) }) - } - return Effect.succeed(json(req, { ok: true })) - }) - - return Effect.gen(function* () { - const events = yield* EventV2Bridge.Service - const share = yield* ShareNext.Service - const session = yield* Session.Service - - const info = yield* session.create({ title: "first" }) - yield* share.init() - yield* Effect.sleep(50) - const { db } = yield* Database.Service - yield* db - .insert(SessionShareTable) - .values({ - session_id: info.id, - id: "shr_abc", - url: "https://legacy-share.example.com/share/abc", - secret: "sec_123", - }) - .run() - .pipe(Effect.orDie) - - yield* events.publish(Session.Event.Diff, { - sessionID: info.id, - diff: [ - { - file: "a.ts", - patch: - "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-one\n\\ No newline at end of file\n+two\n\\ No newline at end of file\n", - additions: 1, - deletions: 1, - status: "modified", - }, - ], - }) - yield* events.publish(Session.Event.Diff, { - sessionID: info.id, - diff: [ - { - file: "b.ts", - patch: - "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n", - additions: 2, - deletions: 0, - status: "modified", - }, - ], - }) - yield* pollWithTimeout( - Effect.sync(() => (seen.length === 1 ? true : undefined)), - "timed out waiting for share sync", - "5 seconds", - ) - - expect(seen).toHaveLength(1) - expect(seen[0].url).toBe("https://legacy-share.example.com/api/share/shr_abc/sync") - - const body = JSON.parse(seen[0].body) as { - secret: string - data: Array<{ - type: string - data: Array<{ - file: string - patch: string - additions: number - deletions: number - status?: string - }> - }> - } - expect(body.secret).toBe("sec_123") - expect(body.data).toHaveLength(1) - expect(body.data[0].type).toBe("session_diff") - expect(body.data[0].data).toEqual([ - { - file: "b.ts", - patch: - "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n", - additions: 2, - deletions: 0, - status: "modified", - }, - ]) - }).pipe(Effect.provide(integrationLayer(client))) - }, - { config: { enterprise: { url: "https://legacy-share.example.com" } } }, - ), - ) -}) diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index d9ef81e40cca..c7ef5c8865f8 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -167,7 +167,6 @@ const TIPS: Tip[] = [ (shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"), "Use {highlight}/undo{/highlight} to revert the last message and file changes", "Use {highlight}/redo{/highlight} to restore previously undone messages and file changes", - "Run {highlight}/share{/highlight} to create a public opencode.ai link", "Drag and drop images or PDFs into the terminal as context", (shortcuts) => press(shortcuts.inputPaste(), "to paste images from your clipboard into the prompt"), (shortcuts) => `Use ${commandText("/editor", shortcuts.editorOpen())} to compose messages in your external editor`, @@ -259,9 +258,6 @@ const TIPS: Tip[] = [ 'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools', 'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server', "Override global tool settings per agent configuration", - 'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions', - 'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing', - "Run {highlight}/unshare{/highlight} to remove a session from public access", "Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops", "Permission {highlight}external_directory{/highlight} protects files outside project", "Run {highlight}opencode debug config{/highlight} to troubleshoot configuration", diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 2b54f21671b3..e1f554f483a0 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -113,12 +113,10 @@ function goUpsellKeys(action: RetryAction) { } const sessionBindingCommands = [ - "session.share", "session.rename", "session.timeline", "session.fork", "session.compact", - "session.unshare", "session.undo", "session.redo", "session.sidebar.toggle", @@ -463,46 +461,6 @@ export function Session() { } const sessionCommandList = createMemo(() => [ - { - title: session()?.share?.url ? "Copy share link" : "Share session", - value: "session.share", - suggested: route.type === "session", - category: "Session", - enabled: sync.data.config.share !== "disabled", - slash: { - name: "share", - }, - run: async () => { - const copy = (url: string) => - clipboard - .write?.(url) - .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" })) - const url = session()?.share?.url - if (url) { - await copy(url) - dialog.clear() - return - } - if (!kv.get("share_consent", false)) { - const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?") - if (ok !== true) return - kv.set("share_consent", true) - } - await sdk.client.session - .share({ - sessionID: route.sessionID, - }) - .then((res) => copy(res.data!.share!.url)) - .catch((error) => { - toast.show({ - message: error instanceof Error ? error.message : "Failed to share session", - variant: "error", - }) - }) - dialog.clear() - }, - }, { title: "Rename session", value: "session.rename", @@ -584,29 +542,6 @@ export function Session() { dialog.clear() }, }, - { - title: "Unshare session", - value: "session.unshare", - category: "Session", - enabled: !!session()?.share?.url, - slash: { - name: "unshare", - }, - run: async () => { - await sdk.client.session - .unshare({ - sessionID: route.sessionID, - }) - .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) - .catch((error) => { - toast.show({ - message: error instanceof Error ? error.message : "Failed to unshare session", - variant: "error", - }) - }) - dialog.clear() - }, - }, { title: "Undo previous message", value: "session.undo", diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 0c5d2b313967..6103e2253200 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -77,9 +77,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - - {session()!.share!.url} -