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
2 changes: 1 addition & 1 deletion bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[install]
exact = true
linker = "isolated"

[test]
root = "./do-not-run-tests-from-root"

54 changes: 54 additions & 0 deletions packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,42 @@ export namespace ACP {

async newSession(params: NewSessionRequest) {
const directory = params.cwd

// If sessionId is provided in config, delegate to loadSession with additional prompt handling
if (this.config.sessionId) {
const result = await this.loadSession({
sessionId: this.config.sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers,
})

// Send initial prompt if provided (after replay completes)
if (this.config.initialPrompt) {
this.sdk.session
.prompt({
sessionID: this.config.sessionId,
directory,
parts: [
{
type: "text",
text: this.config.initialPrompt,
},
],
})
.catch((err) => {
log.error("failed to send initial prompt", { error: err, sessionId: this.config.sessionId })
})
}

return {
sessionId: this.config.sessionId,
models: result.models,
modes: result.modes,
_meta: {},
}
}

// Normal new session creation
try {
const model = await defaultModel(this.config, directory)

Expand All @@ -411,6 +447,24 @@ export namespace ACP {

this.setupEventSubscriptions(state)

// Send initial prompt if provided (don't await - let it stream via events)
if (this.config.initialPrompt) {
this.sdk.session
.prompt({
sessionID: sessionId,
directory,
parts: [
{
type: "text",
text: this.config.initialPrompt!,
},
],
})
.catch((err) => {
log.error("failed to send initial prompt", { error: err, sessionId })
})
}

return {
sessionId,
models: load.models,
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/acp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ export interface ACPConfig {
providerID: string
modelID: string
}
initialPrompt?: string
sessionId?: string
}
51 changes: 42 additions & 9 deletions packages/opencode/src/cli/cmd/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,54 @@ import { ACP } from "@/acp/agent"
import { Server } from "@/server/server"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { parseSessionUrl } from "@/util/parse-session-url"

const log = Log.create({ service: "acp-command" })

export const AcpCommand = cmd({
command: "acp",
describe: "start ACP (Agent Client Protocol) server",
builder: (yargs) => {
return withNetworkOptions(yargs).option("cwd", {
describe: "working directory",
type: "string",
default: process.cwd(),
})
return withNetworkOptions(yargs)
.option("cwd", {
describe: "working directory",
type: "string",
default: process.cwd(),
})
.option("prompt", {
describe: "prompt to use",
type: "string",
})
.option("attach", {
describe: "attach to existing server URL or session URL instead of starting new one",
type: "string",
})
.option("session", {
describe: "session id to continue",
type: "string",
alias: ["s"],
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
let server: ReturnType<typeof Server.listen> | undefined
let baseUrl: string
let sessionId: string | undefined

// If attach URL is provided, use it instead of starting a server
if (args.attach) {
const parsed = parseSessionUrl(args.attach)
baseUrl = parsed.baseUrl
sessionId = args.session ?? parsed.sessionId
} else {
const opts = await resolveNetworkOptions(args)
server = Server.listen(opts)
baseUrl = `http://${server.hostname}:${server.port}`
sessionId = args.session
}

const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
baseUrl,
})

const input = new WritableStream<Uint8Array>({
Expand Down Expand Up @@ -55,7 +83,7 @@ export const AcpCommand = cmd({
const agent = await ACP.init({ sdk })

new AgentSideConnection((conn) => {
return agent.create(conn, { sdk })
return agent.create(conn, { sdk, initialPrompt: args.prompt, sessionId })
}, stream)

log.info("setup connection")
Expand All @@ -64,6 +92,11 @@ export const AcpCommand = cmd({
process.stdin.on("end", resolve)
process.stdin.on("error", reject)
})

// Only stop server if we started one
if (server) {
await server.stop()
}
})
},
})
101 changes: 94 additions & 7 deletions packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,106 @@ import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
import { parseSessionUrl } from "@/util/parse-session-url"

export const ServeCommand = cmd({
command: "serve",
builder: (yargs) => withNetworkOptions(yargs),
builder: (yargs) => {
return withNetworkOptions(yargs)
.option("prompt", {
describe: "prompt to use",
type: "string",
})
.option("attach", {
describe: "attach to an existing OpenCode server or session URL",
type: "string",
})
},
describe: "starts a headless opencode server",
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
let server: ReturnType<typeof Server.listen> | Awaited<ReturnType<typeof Bun.serve>> | undefined
let baseUrl: string
let remoteUrl: string | undefined
let sessionId: string | undefined

if (args.attach) {
const parsed = parseSessionUrl(args.attach)
remoteUrl = parsed.baseUrl
sessionId = parsed.sessionId
const opts = await resolveNetworkOptions(args)

// Create a proxy server that forwards to the remote server
const app = new Hono()
app.all("*", async (c) => {
const url = new URL(c.req.url)
const targetUrl = `${remoteUrl}${url.pathname}${url.search}`
return proxy(targetUrl, {
...c.req,
})
})

server = Bun.serve({
hostname: opts.hostname,
port: opts.port,
fetch: app.fetch,
})

baseUrl = `http://${server.hostname}:${server.port}`
} else {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
server = Server.listen(opts)
baseUrl = `http://${server.hostname}:${server.port}`
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)

// If prompt is provided or sessionId is provided, create/use session
if (args.prompt || sessionId) {
const sdk = createOpencodeClient({
baseUrl: remoteUrl ?? baseUrl,
})

let actualSessionId: string

if (sessionId) {
// Use existing session from URL
actualSessionId = sessionId
} else {
// Create new session
const session = await sdk.session.create({ directory: process.cwd() })
if (!session.data) throw new Error("Failed to create session")
actualSessionId = session.data.id
}

// Send the prompt to the session if provided (fire and forget)
if (args.prompt) {
sdk.session
.prompt({
sessionID: actualSessionId,
directory: process.cwd(),
parts: [
{
type: "text",
text: args.prompt,
},
],
})
.catch(() => {})
}

console.log(`opencode server listening on ${baseUrl}`)
console.log(`session created: ${baseUrl}/${actualSessionId}/session/${actualSessionId}`)
} else {
console.log(`opencode server listening on ${baseUrl}`)
}

await new Promise(() => {})
await server.stop()
if (server) {
await server.stop()
}
},
})
24 changes: 21 additions & 3 deletions packages/opencode/src/cli/cmd/tui/attach.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { cmd } from "../cmd"
import { tui } from "./app"
import { iife } from "@/util/iife"
import { parseSessionUrl } from "@/util/parse-session-url"

export const AttachCommand = cmd({
command: "attach <url>",
Expand All @@ -8,7 +10,7 @@ export const AttachCommand = cmd({
yargs
.positional("url", {
type: "string",
describe: "http://localhost:4096",
describe: "http://localhost:4096 or http://localhost:4096/ses_xxx/session/ses_xxx",
demandOption: true,
})
.option("dir", {
Expand All @@ -19,12 +21,28 @@ export const AttachCommand = cmd({
alias: ["s"],
type: "string",
describe: "session id to continue",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
}),
handler: async (args) => {
if (args.dir) process.chdir(args.dir)

const prompt = await iife(async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})

const { baseUrl, sessionId } = parseSessionUrl(args.url)

await tui({
url: args.url,
args: { sessionID: args.session },
url: baseUrl,
args: {
sessionID: args.session ?? sessionId,
prompt,
},
directory: args.dir ? process.cwd() : undefined,
})
},
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { useArgs } from "../../context/args"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
Expand Down Expand Up @@ -188,6 +189,7 @@ export function Session() {

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

// Handle initial prompt from fork
createEffect(() => {
Expand All @@ -213,6 +215,18 @@ export function Session() {
}
})

// Handle prompt from CLI args (--prompt with session URL)
// Only submit if we attached to an existing session (args.sessionID was provided)
let promptSubmitted = false
createEffect(() => {
if (promptSubmitted || !args.prompt || !args.sessionID || !prompt) return
if (!sync.session.get(route.sessionID)) return // Wait for session to load

promptSubmitted = true
prompt.set({ input: args.prompt, parts: [] })
prompt.submit()
})

let scroll: ScrollBoxRenderable
let prompt: PromptRef
const keybind = useKeybind()
Expand Down
Loading