Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/opencode/bin/opencode
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env node

const childProcess = require("child_process")
const fs = require("fs")
const path = require("path")
const os = require("os")
import childProcess from "child_process"
import fs from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"

function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
Expand All @@ -22,7 +23,7 @@ if (envPath) {
run(envPath)
}

const scriptPath = fs.realpathSync(__filename)
const scriptPath = fs.realpathSync(fileURLToPath(import.meta.url))
const scriptDir = path.dirname(scriptPath)

//
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `session` ADD `pending_context` text;
61 changes: 48 additions & 13 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,18 +379,16 @@ export const RunCommand = cmd({
}

async function session(sdk: OpencodeClient) {
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session

if (baseID && args.fork) {
const forked = await sdk.session.fork({ sessionID: baseID })
return forked.data?.id
}

if (baseID) return baseID

const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
return resolveSession({
args: {
continue: !!args.continue,
fork: !!args.fork,
session: args.session,
},
sdk,
title: title(),
rules,
})
}

async function share(sdk: OpencodeClient, sessionID: string) {
Expand Down Expand Up @@ -619,7 +617,8 @@ export const RunCommand = cmd({
return args.agent
})()

const sessionID = await session(sdk)
const result = await session(sdk)
const sessionID = await activateSession({ sdk, result })
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
Expand Down Expand Up @@ -674,3 +673,39 @@ export const RunCommand = cmd({
})
},
})

export async function resolveSession(input: {
args: {
continue: boolean
fork: boolean
session?: string
}
sdk: Pick<OpencodeClient, "session">
title?: string
rules: PermissionNext.Ruleset
}) {
const baseID = input.args.continue
? (await input.sdk.session.list()).data?.find((s) => !s.parentID)?.id
: input.args.session

if (baseID && input.args.fork) {
const forked = await input.sdk.session.fork({ sessionID: baseID })
return { id: forked.data?.id, resume: false }
}

if (baseID) return { id: baseID, resume: true }

const result = await input.sdk.session.create({ title: input.title, permission: input.rules })
return { id: result.data?.id, resume: false }
}

export async function activateSession(input: {
sdk: Pick<OpencodeClient, "session">
result: { id?: string; resume: boolean }
}) {
if (!input.result.id) return
if (input.result.resume) {
await input.sdk.session.resume({ sessionID: input.result.id })
}
return input.result.id
}
4 changes: 2 additions & 2 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ function App() {
if (args.fork) {
sdk.client.session.fork({ sessionID: match }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
route.navigate({ type: "session", sessionID: result.data.id, resume: false })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
Expand All @@ -340,7 +340,7 @@ function App() {
forked = true
sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
route.navigate({ type: "session", sessionID: result.data.id, resume: false })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ async function openWorkspace(input: {
input.route.navigate({
type: "session",
sessionID: created.id,
resume: false,
})
input.dialog.clear()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,7 @@ export function Prompt(props: PromptProps) {
route.navigate({
type: "session",
sessionID,
resume: false,
})
}, 50)
input.clear()
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/context/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type SessionRoute = {
type: "session"
sessionID: string
initialPrompt?: PromptInfo
resume?: boolean
}

export type Route = HomeRoute | SessionRoute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
sessionID: forked.data!.id,
type: "session",
initialPrompt,
resume: false,
})
dialog.clear()
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export function DialogMessage(props: {
sessionID: result.data!.id,
type: "session",
initialPrompt,
resume: false,
})
dialog.clear()
},
Expand Down
32 changes: 20 additions & 12 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { syncSession } from "./resume"

addDefaultParsers(parsers.parsers)

Expand Down Expand Up @@ -120,6 +121,8 @@ export function Session() {
const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
const sdk = useSDK()
const toast = useToast()
const promptRef = usePromptRef()
const session = createMemo(() => sync.session.get(route.sessionID))
const children = createMemo(() => {
Expand Down Expand Up @@ -188,25 +191,30 @@ export function Session() {
}
})

let resumed: string | undefined
createEffect(async () => {
await sync.session
.sync(route.sessionID)
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
.catch((e) => {
const sessionID = route.sessionID
resumed = await syncSession({
sessionID,
resume: route.resume,
resumed,
sync: sync.session.sync,
resumeSession: sdk.client.session.resume,
onScroll: () => scroll?.scrollBy(100_000),
onResumeError: (err) => {
console.error(err)
},
onSyncError: (e) => {
console.error(e)
toast.show({
message: `Session not found: ${route.sessionID}`,
message: `Session not found: ${sessionID}`,
variant: "error",
})
return navigate({ type: "home" })
})
},
onMissing: () => navigate({ type: "home" }),
})
})

const toast = useToast()
const sdk = useSDK()

// Handle initial prompt from fork
createEffect(() => {
if (route.initialPrompt && prompt) {
Expand Down
34 changes: 34 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/resume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export function shouldResume(input: { sessionID: string; resume?: boolean; resumed?: string }) {
return input.resume !== false && input.resumed !== input.sessionID
}

export async function syncSession(input: {
sessionID: string
resume?: boolean
resumed?: string
sync: (sessionID: string) => Promise<unknown>
resumeSession: (input: { sessionID: string }) => Promise<unknown>
onScroll?: () => void
onResumeError: (err: unknown) => void
onSyncError: (err: unknown) => void
onMissing: () => void
}) {
try {
await input.sync(input.sessionID)
} catch (err) {
input.onSyncError(err)
input.onMissing()
return input.resumed
}

input.onScroll?.()
if (!shouldResume(input)) return input.resumed

try {
await input.resumeSession({ sessionID: input.sessionID })
return input.sessionID
} catch (err) {
input.onResumeError(err)
return undefined
}
}
32 changes: 32 additions & 0 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { SessionStart } from "@/session/start"

const log = Log.create({ service: "server" })

Expand Down Expand Up @@ -354,6 +355,37 @@ export const SessionRoutes = lazy(() =>
return c.json(result)
},
)
.post(
"/:sessionID/resume",
describeRoute({
summary: "Resume session",
description: "Mark an existing session as resumed and run session lifecycle hooks.",
operationId: "session.resume",
responses: {
200: {
description: "Resumed session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: SessionID.zod,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await Session.get(sessionID)
await SessionStart.trigger({ sessionID, trigger: "resume" })
return c.json(true)
},
)
.post(
"/:sessionID/abort",
describeRoute({
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionStart } from "./start"

export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
Expand Down Expand Up @@ -292,6 +293,8 @@ When constructing the summary, try to stick to this template:
}
}
if (processor.message.error) return "stop"
// Only fires after successful compaction, not on error or "stop" paths
await SessionStart.trigger({ sessionID: input.sessionID, trigger: "compact" })
Bus.publish(Event.Compacted, { sessionID: input.sessionID })
return "continue"
}
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { Permission } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"
import { SessionStart } from "./start"

export namespace Session {
const log = Log.create({ service: "session" })
Expand Down Expand Up @@ -331,6 +332,10 @@ export namespace Session {
share(result.id).catch(() => {
// Silently ignore sharing errors during session creation
})
// Fire session.start hook for fresh sessions (including forks). Runs after
// the row is persisted but before fork message copying finishes, so plugins
// that inspect message history during startup will see an empty fork.
await SessionStart.trigger({ sessionID: result.id, trigger: "startup" })
Bus.publish(Event.Updated, {
info: result,
})
Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@ import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
import { Permission } from "@/permission"
import { Auth } from "@/auth"
import { SessionID } from "./schema"
import { SessionStart } from "./start"

export namespace LLM {
const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX

function wrap(input: string[]) {
return ["<session-start-context>", ...input, "</session-start-context>"].join("\n\n")
}

export type StreamInput = {
user: MessageV2.User
sessionID: string
Expand Down Expand Up @@ -68,12 +74,22 @@ export namespace LLM {
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"

const system: string[] = []
// Consume one-shot session-start context injected by plugins via the
// session.start hook. take() reads and clears in a single Database.use()
// call. If the stream setup fails after this point, the context is lost —
// this is an accepted tradeoff to keep the code simple. Because this state
// is only cleared on the next LLM turn, pending context can also become
// stale if a session is resumed/start-triggered but not prompted soon after.
// If plugins start depending on fresher resume state, consider trigger-aware
// expiry or replacement semantics instead of unbounded accumulation.
const pending = await SessionStart.take(SessionID.make(input.sessionID))
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
...(pending.length > 0 ? [wrap(pending)] : []),
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/session.sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const SessionTable = sqliteTable(
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
pending_context: text({ mode: "json" }).$type<string[]>(),
...Timestamps,
time_compacting: integer(),
time_archived: integer(),
Expand Down
Loading
Loading