summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authormonke-yo <[email protected]>2025-11-05 23:01:01 +0530
committerGitHub <[email protected]>2025-11-05 11:31:01 -0600
commitee8b81269bdd40dde0987e7796ae0ebcd8ebc3ab (patch)
tree7f2adf35e31025ce5ace7626cad8a3d3e1d4a2ae
parent53998a2fedc55262aca8b049eacbed3d710a46e5 (diff)
downloadopencode-ee8b81269bdd40dde0987e7796ae0ebcd8ebc3ab.tar.gz
opencode-ee8b81269bdd40dde0987e7796ae0ebcd8ebc3ab.zip
feat: add --attach flag to opencode run (#3889)
-rw-r--r--packages/opencode/src/cli/cmd/run.ts383
-rw-r--r--packages/web/src/content/docs/cli.mdx12
2 files changed, 210 insertions, 185 deletions
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index a979f8baa..39b0a55fd 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -1,21 +1,14 @@
import type { Argv } from "yargs"
import path from "path"
-import { Bus } from "../../bus"
-import { Provider } from "../../provider/provider"
-import { Session } from "../../session"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
-import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
-import { MessageV2 } from "../../session/message-v2"
-import { Identifier } from "../../id/id"
-import { Agent } from "../../agent/agent"
import { Command } from "../../command"
-import { SessionPrompt } from "../../session/prompt"
import { EOL } from "os"
-import { Permission } from "@/permission"
import { select } from "@clack/prompts"
+import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"
+import { Server } from "../../server/server"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -84,11 +77,19 @@ export const RunCommand = cmd({
type: "string",
describe: "title for the session (uses truncated prompt if no value provided)",
})
+ .option("attach", {
+ type: "string",
+ describe: "attach to a running opencode server (e.g., http://localhost:4096)",
+ })
+ .option("port", {
+ type: "number",
+ describe: "port for the local server (defaults to random port if no value provided)",
+ })
},
handler: async (args) => {
let message = args.message.join(" ")
- let fileParts: any[] = []
+ const fileParts: any[] = []
if (args.file) {
const files = Array.isArray(args.file) ? args.file : [args.file]
@@ -124,216 +125,228 @@ export const RunCommand = cmd({
process.exit(1)
}
- await bootstrap(process.cwd(), async () => {
- if (args.command) {
- const exists = await Command.get(args.command)
- if (!exists) {
- UI.error(`Command "${args.command}" not found`)
- process.exit(1)
+ const execute = async (sdk: OpencodeClient, sessionID: string) => {
+ const printEvent = (color: string, type: string, title: string) => {
+ UI.println(
+ color + `|`,
+ UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
+ "",
+ UI.Style.TEXT_NORMAL + title,
+ )
+ }
+
+ const outputJsonEvent = (type: string, data: any) => {
+ if (args.format === "json") {
+ process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
+ return true
}
+ return false
}
- const session = await (async () => {
- if (args.continue) {
- const it = Session.list()
- try {
- for await (const s of it) {
- if (s.parentID === undefined) {
- return s
+
+ const events = await sdk.event.subscribe()
+ let errorMsg: string | undefined
+
+ const eventProcessor = (async () => {
+ for await (const event of events.stream) {
+ if (event.type === "message.part.updated") {
+ const part = event.properties.part
+ if (part.sessionID !== sessionID) continue
+
+ if (part.type === "tool" && part.state.status === "completed") {
+ if (outputJsonEvent("tool_use", { part })) continue
+ const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
+ const title =
+ part.state.title ||
+ (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
+ printEvent(color, tool, title)
+ if (part.tool === "bash" && part.state.output?.trim()) {
+ UI.println()
+ UI.println(part.state.output)
}
}
- return
- } finally {
- await it.return()
- }
- }
- if (args.session) return Session.get(args.session)
+ if (part.type === "step-start") {
+ if (outputJsonEvent("step_start", { part })) continue
+ }
- const title = (() => {
- if (args.title !== undefined) {
- if (args.title === "") {
- return message.slice(0, 50) + (message.length > 50 ? "..." : "")
+ if (part.type === "step-finish") {
+ if (outputJsonEvent("step_finish", { part })) continue
+ }
+
+ if (part.type === "text" && part.time?.end) {
+ if (outputJsonEvent("text", { part })) continue
+ const isPiped = !process.stdout.isTTY
+ if (!isPiped) UI.println()
+ process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
+ if (!isPiped) UI.println()
}
- return args.title
}
- return undefined
- })()
- return Session.create({
- title,
- })
- })()
+ if (event.type === "session.error") {
+ const props = event.properties
+ if (props.sessionID !== sessionID || !props.error) continue
+ let err = String(props.error.name)
+ if ("data" in props.error && props.error.data && "message" in props.error.data) {
+ err = String(props.error.data.message)
+ }
+ errorMsg = errorMsg ? errorMsg + EOL + err : err
+ if (outputJsonEvent("error", { error: props.error })) continue
+ UI.error(err)
+ }
- if (!session) {
- UI.error("Session not found")
- process.exit(1)
- }
+ if (event.type === "session.idle" && event.properties.sessionID === sessionID) {
+ break
+ }
- const cfg = await Config.get()
- if (cfg.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share) {
- try {
- await Session.share(session.id)
- UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8))
- } catch (error) {
- if (error instanceof Error && error.message.includes("disabled")) {
- UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
- } else {
- throw error
+ if (event.type === "permission.updated") {
+ const permission = event.properties
+ if (permission.sessionID !== sessionID) continue
+ const result = await select({
+ message: `Permission required to run: ${permission.title}`,
+ options: [
+ { value: "once", label: "Allow once" },
+ { value: "always", label: "Always allow" },
+ { value: "reject", label: "Reject" },
+ ],
+ initialValue: "once",
+ }).catch(() => "reject")
+ const response = (result.toString().includes("cancel") ? "reject" : result) as
+ | "once"
+ | "always"
+ | "reject"
+ await sdk.postSessionIdPermissionsPermissionId({
+ path: { id: sessionID, permissionID: permission.id },
+ body: { response },
+ })
}
}
- }
-
- const agent = await (async () => {
- if (args.agent) return Agent.get(args.agent)
- const build = Agent.get("build")
- if (build) return build
- return Agent.list().then((x) => x[0])
})()
- const { providerID, modelID } = await (async () => {
- if (args.model) return Provider.parseModel(args.model)
- if (agent.model) return agent.model
- return await Provider.defaultModel()
- })()
-
- function printEvent(color: string, type: string, title: string) {
- UI.println(
- color + `|`,
- UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
- "",
- UI.Style.TEXT_NORMAL + title,
- )
+ if (args.command) {
+ await sdk.session.command({
+ path: { id: sessionID },
+ body: {
+ agent: args.agent || "build",
+ model: args.model,
+ command: args.command,
+ arguments: message,
+ },
+ })
+ } else {
+ const modelParam = args.model
+ ? (() => {
+ const [providerID, modelID] = args.model.split("/")
+ return { providerID, modelID }
+ })()
+ : undefined
+ await sdk.session.prompt({
+ path: { id: sessionID },
+ body: {
+ agent: args.agent || "build",
+ model: modelParam,
+ parts: [...fileParts, { type: "text", text: message }],
+ },
+ })
}
- function outputJsonEvent(type: string, data: any) {
- if (args.format === "json") {
- const jsonEvent = {
- type,
- timestamp: Date.now(),
- sessionID: session?.id,
- ...data,
- }
- process.stdout.write(JSON.stringify(jsonEvent) + EOL)
- return true
- }
- return false
- }
+ await eventProcessor
+ if (errorMsg) process.exit(1)
+ }
- const messageID = Identifier.ascending("message")
+ if (args.attach) {
+ const sdk = createOpencodeClient({ baseUrl: args.attach })
- Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
- if (evt.properties.part.sessionID !== session.id) return
- if (evt.properties.part.messageID === messageID) return
- const part = evt.properties.part
+ const sessionID = await (async () => {
+ if (args.continue) {
+ const result = await sdk.session.list()
+ return result.data?.find((s) => !s.parentID)?.id
+ }
+ if (args.session) return args.session
- if (part.type === "tool" && part.state.status === "completed") {
- if (outputJsonEvent("tool_use", { part })) return
- const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
- const title =
- part.state.title ||
- (Object.keys(part.state.input).length > 0
- ? JSON.stringify(part.state.input)
- : "Unknown")
+ const title =
+ args.title !== undefined
+ ? args.title === ""
+ ? message.slice(0, 50) + (message.length > 50 ? "..." : "")
+ : args.title
+ : undefined
- printEvent(color, tool, title)
+ const result = await sdk.session.create({ body: title ? { title } : {} })
+ return result.data?.id
+ })()
- if (part.tool === "bash" && part.state.output && part.state.output.trim()) {
- UI.println()
- UI.println(part.state.output)
+ if (!sessionID) {
+ UI.error("Session not found")
+ process.exit(1)
+ }
+
+ const cfgResult = await sdk.config.get()
+ if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
+ const shareResult = await sdk.session.share({ path: { id: sessionID } }).catch((error) => {
+ if (error instanceof Error && error.message.includes("disabled")) {
+ UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
+ return { error }
+ })
+ if (!shareResult.error) {
+ UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8))
}
+ }
- if (part.type === "step-start") {
- if (outputJsonEvent("step_start", { part })) return
- }
+ return await execute(sdk, sessionID)
+ }
- if (part.type === "step-finish") {
- if (outputJsonEvent("step_finish", { part })) return
- }
+ await bootstrap(process.cwd(), async () => {
+ const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" })
+ const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` })
- if (part.type === "text") {
- const text = part.text
- const isPiped = !process.stdout.isTTY
+ if (args.command) {
+ const exists = await Command.get(args.command)
+ if (!exists) {
+ server.stop()
+ UI.error(`Command "${args.command}" not found`)
+ process.exit(1)
+ }
+ }
- if (part.time?.end) {
- if (outputJsonEvent("text", { part })) return
- if (!isPiped) UI.println()
- process.stdout.write((isPiped ? text : UI.markdown(text)) + EOL)
- if (!isPiped) UI.println()
- }
+ const sessionID = await (async () => {
+ if (args.continue) {
+ const result = await sdk.session.list()
+ return result.data?.find((s) => !s.parentID)?.id
}
- })
+ if (args.session) return args.session
- let errorMsg: string | undefined
- Bus.subscribe(Session.Event.Error, async (evt) => {
- const { sessionID, error } = evt.properties
- if (sessionID !== session.id || !error) return
- let err = String(error.name)
+ const title =
+ args.title !== undefined
+ ? args.title === ""
+ ? message.slice(0, 50) + (message.length > 50 ? "..." : "")
+ : args.title
+ : undefined
- if ("data" in error && error.data && "message" in error.data) {
- err = error.data.message
- }
- errorMsg = errorMsg ? errorMsg + EOL + err : err
+ const result = await sdk.session.create({ body: title ? { title } : {} })
+ return result.data?.id
+ })()
- if (outputJsonEvent("error", { error })) return
- UI.error(err)
- })
+ if (!sessionID) {
+ server.stop()
+ UI.error("Session not found")
+ process.exit(1)
+ }
- Bus.subscribe(Permission.Event.Updated, async (evt) => {
- const permission = evt.properties
- const message = `Permission required to run: ${permission.title}`
-
- const result = await select({
- message,
- options: [
- { value: "once", label: "Allow once" },
- { value: "always", label: "Always allow" },
- { value: "reject", label: "Reject" },
- ],
- initialValue: "once",
- }).catch(() => "reject")
- const response = (result.toString().includes("cancel") ? "reject" : result) as
- | "once"
- | "always"
- | "reject"
-
- Permission.respond({
- sessionID: session.id,
- permissionID: permission.id,
- response,
+ const cfgResult = await sdk.config.get()
+ if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
+ const shareResult = await sdk.session.share({ path: { id: sessionID } }).catch((error) => {
+ if (error instanceof Error && error.message.includes("disabled")) {
+ UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
+ }
+ return { error }
})
- })
-
- await (async () => {
- if (args.command) {
- return await SessionPrompt.command({
- messageID,
- sessionID: session.id,
- agent: agent.name,
- model: providerID + "/" + modelID,
- command: args.command,
- arguments: message,
- })
+ if (!shareResult.error) {
+ UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8))
}
- return await SessionPrompt.prompt({
- sessionID: session.id,
- messageID,
- model: {
- providerID,
- modelID,
- },
- agent: agent.name,
- parts: [
- ...fileParts,
- {
- id: Identifier.ascending("part"),
- type: "text",
- text: message,
- },
- ],
- })
- })()
- if (errorMsg) process.exit(1)
+ }
+
+ await execute(sdk, sessionID)
+ server.stop()
})
},
})
diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx
index 1312442d6..083db369b 100644
--- a/packages/web/src/content/docs/cli.mdx
+++ b/packages/web/src/content/docs/cli.mdx
@@ -184,6 +184,16 @@ This is useful for scripting, automation, or when you want a quick answer withou
opencode run Explain the use of context in Go
```
+You can also attach to a running `opencode serve` instance to avoid MCP server cold boot times on every run:
+
+```bash
+# Start a headless server in one terminal
+opencode serve
+
+# In another terminal, run commands that attach to it
+opencode run --attach http://localhost:4096 "Explain async/await in JavaScript"
+```
+
#### Flags
| Flag | Short | Description |
@@ -197,6 +207,8 @@ opencode run Explain the use of context in Go
| `--file` | `-f` | File(s) to attach to message |
| `--format` | | Format: default (formatted) or json (raw JSON events) |
| `--title` | | Title for the session (uses truncated prompt if no value provided) |
+| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) |
+| `--port` | | Port for the local server (defaults to random port) |
---