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
135 changes: 109 additions & 26 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,51 @@ function resolveRunInput(value?: string, piped?: string): string | undefined {
return value + "\n" + piped
}

export async function resolveRunDirectory(input: {
attach: boolean
directory: string | undefined
explicitDirectory: boolean
root: string
sessionDirectory: string | undefined
current: () => Promise<string>
}) {
if (input.attach) return input.directory ?? input.sessionDirectory ?? input.current()
if (input.explicitDirectory) return input.directory ?? input.sessionDirectory ?? input.root
return input.sessionDirectory ?? input.directory ?? input.root
}

export function resolveRunFilePath(input: { attach: boolean; root: string; cwd: string; filePath: string }) {
return path.resolve(input.attach ? input.root : input.cwd, input.filePath)
}

export function resolveContinueListQuery(input: { attach: boolean; explicitDirectory: boolean }) {
if (input.attach || input.explicitDirectory) return undefined
return { scope: "project" as const }
}

export async function missingLocalSessionDirectory(input: {
attach: boolean
explicitDirectory: boolean
sessionDirectory: string | undefined
exists: (directory: string) => Promise<boolean>
}) {
return !input.attach && !input.explicitDirectory && !!input.sessionDirectory && !(await input.exists(input.sessionDirectory))
}

export async function resolveContinueSession<T extends SessionInfo & { parentID?: string }>(input: {
attach: boolean
explicitDirectory: boolean
sessions: T[]
exists: (directory: string) => Promise<boolean>
}) {
const roots = input.sessions.filter((item) => !item.parentID)
if (input.attach || input.explicitDirectory) return roots[0]

for (const item of roots) {
if (!(await missingLocalSessionDirectory({ ...input, sessionDirectory: item.directory }))) return item
}
}

type FilePart = {
type: "file"
url: string
Expand Down Expand Up @@ -330,13 +375,39 @@ export const RunCommand = effectCmd({
headers: attachHeaders,
})
}
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("@/server/server")
const request = new Request(input, init)
return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch
const localSDK = (dir: string) =>
createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: fetchFn,
directory: dir,
})
const clientForDirectory = async (sdk: OpencodeClient, sessionDirectory: string | undefined) => {
const cwd = await resolveRunDirectory({
attach: !!args.attach,
directory,
explicitDirectory: !!args.dir,
root,
sessionDirectory,
current: () => current(sdk),
})
return {
cwd,
client: args.attach ? attachSDK(cwd) : cwd === directory ? sdk : localSDK(cwd),
}
}

const files: FilePart[] = []
if (args.file) {
async function resolveFiles(cwd: string) {
const files: FilePart[] = []
if (!args.file) return files
const list = Array.isArray(args.file) ? args.file : [args.file]

for (const filePath of list) {
const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath)
const resolvedPath = resolveRunFilePath({ attach: !!args.attach, root, cwd, filePath })
if (!(await Filesystem.exists(resolvedPath))) {
UI.error(`File not found: ${filePath}`)
process.exit(1)
Expand All @@ -351,6 +422,7 @@ export const RunCommand = effectCmd({
mime,
})
}
return files
}

const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
Expand Down Expand Up @@ -405,9 +477,21 @@ export const RunCommand = effectCmd({
UI.error("Session not found")
process.exit(1)
}
if (
await missingLocalSessionDirectory({
attach: !!args.attach,
explicitDirectory: !!args.dir,
sessionDirectory: current.data.directory,
exists: Filesystem.exists,
})
) {
UI.error(`Session directory not found: ${current.data.directory}`)
process.exit(1)
}

if (args.fork) {
const forked = await sdk.session.fork({
const target = await clientForDirectory(sdk, current.data.directory)
const forked = await target.client.session.fork({
sessionID: args.session,
})
const id = forked.data?.id
Expand All @@ -418,7 +502,7 @@ export const RunCommand = effectCmd({
return {
id,
title: forked.data?.title ?? current.data.title,
directory: forked.data?.directory ?? current.data.directory,
directory: forked.data?.directory ?? target.cwd,
}
}

Expand All @@ -429,10 +513,18 @@ export const RunCommand = effectCmd({
}
}

const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
const base = args.continue
? await resolveContinueSession({
attach: !!args.attach,
explicitDirectory: !!args.dir,
sessions: (await sdk.session.list(resolveContinueListQuery({ attach: !!args.attach, explicitDirectory: !!args.dir }))).data ?? [],
exists: Filesystem.exists,
})
: undefined

if (base && args.fork) {
const forked = await sdk.session.fork({
const target = await clientForDirectory(sdk, base.directory)
const forked = await target.client.session.fork({
sessionID: base.id,
})
const id = forked.data?.id
Expand All @@ -443,7 +535,7 @@ export const RunCommand = effectCmd({
return {
id,
title: forked.data?.title ?? base.title,
directory: forked.data?.directory ?? base.directory,
directory: forked.data?.directory ?? target.cwd,
}
}

Expand Down Expand Up @@ -558,7 +650,7 @@ export const RunCommand = effectCmd({
return name
}

async function attachAgent(sdk: OpencodeClient) {
async function attachAgent(sdk: OpencodeClient, source: string | undefined) {
if (!args.agent) return undefined
const name = args.agent

Expand All @@ -571,7 +663,7 @@ export const RunCommand = effectCmd({
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`failed to list agents from ${args.attach}. Falling back to default agent`,
`failed to list agents from ${source ?? "resolved directory"}. Falling back to default agent`,
)
return undefined
}
Expand All @@ -598,10 +690,10 @@ export const RunCommand = effectCmd({
return name
}

async function pickAgent(sdk: OpencodeClient) {
async function pickAgent(sdk: OpencodeClient, cwd: string) {
if (!args.agent) return undefined
if (args.attach) {
return attachAgent(sdk)
if (args.attach || cwd !== directory) {
return attachAgent(sdk, args.attach ?? cwd)
}

return localAgent()
Expand Down Expand Up @@ -757,11 +849,11 @@ export const RunCommand = effectCmd({
}
return error
}
const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
const client = args.attach ? attachSDK(cwd) : sdk
const { cwd, client } = await clientForDirectory(sdk, sess.directory)
const files = await resolveFiles(cwd)

// Validate agent if specified
const agent = await pickAgent(client)
const agent = await pickAgent(client, cwd)

await share(client, sessionID)

Expand Down Expand Up @@ -832,11 +924,7 @@ export const RunCommand = effectCmd({
if (args.interactive && !args.attach && !args.session && !args.continue) {
const model = pick(args.model)
const { runInteractiveLocalMode } = await runtimeTask
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("@/server/server")
const request = new Request(input, init)
return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch
const files = await resolveFiles(directory ?? root)

try {
return await runInteractiveLocalMode({
Expand Down Expand Up @@ -866,11 +954,6 @@ export const RunCommand = effectCmd({
return await execute(sdk)
}

const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("@/server/server")
const request = new Request(input, init)
return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: fetchFn,
Expand Down
47 changes: 44 additions & 3 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "./validate-session"
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
import { Effect, Schema } from "effect"

declare global {
const OPENCODE_WORKER_PATH: string
Expand Down Expand Up @@ -76,6 +79,37 @@ export function resolveThreadDirectory(project?: string, envPWD = process.env.PW
return Filesystem.resolve(cwd)
}

export async function resolveThreadTargetDirectory(input: {
project?: string
sessionID?: string
envPWD?: string
cwd?: string
loadSession: (sessionID: string) => Promise<{ directory?: string } | undefined>
exists: (directory: string) => Promise<boolean>
}) {
const launch = resolveThreadDirectory(input.project, input.envPWD, input.cwd)
if (input.project || !input.sessionID) return launch

const session = await input.loadSession(input.sessionID)
if (!session?.directory) return launch
if (!(await input.exists(session.directory))) throw new Error(`Session directory not found: ${session.directory}`)
return Filesystem.resolve(session.directory)
}

async function loadStoredSession(sessionID: string) {
try {
const decoded = Schema.decodeUnknownSync(SessionID)(sessionID)
return await Effect.runPromise(
Session.Service.use((session) => session.get(decoded)).pipe(
Effect.provide(Session.defaultLayer),
Effect.catch(() => Effect.succeed<Session.Info | undefined>(undefined)),
),
)
} catch {
return undefined
}
}

export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
Expand Down Expand Up @@ -127,9 +161,16 @@ export const TuiThreadCommand = cmd({
return
}

// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const next = resolveThreadDirectory(args.project)
// Resolve relative --project paths from PWD. When resuming a session
// without an explicit project path, boot the TUI worker in the stored
// session directory so UI state, SDK routing, and tool execution share
// the same project instance.
const next = await resolveThreadTargetDirectory({
project: args.project,
sessionID: args.session,
loadSession: loadStoredSession,
exists: Filesystem.exists,
})
const file = await target()
try {
process.chdir(next)
Expand Down
Loading
Loading