feat: add --attach flag to opencode run (#3889)

This commit is contained in:
monke-yo
2025-11-05 23:01:01 +05:30
committed by GitHub
parent 53998a2fed
commit ee8b81269b
2 changed files with 222 additions and 197 deletions

View File

@@ -1,21 +1,14 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import path from "path" import path from "path"
import { Bus } from "../../bus"
import { Provider } from "../../provider/provider"
import { Session } from "../../session"
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Flag } from "../../flag/flag" import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap" 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 { Command } from "../../command"
import { SessionPrompt } from "../../session/prompt"
import { EOL } from "os" import { EOL } from "os"
import { Permission } from "@/permission"
import { select } from "@clack/prompts" import { select } from "@clack/prompts"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../../server/server"
const TOOL: Record<string, [string, string]> = { const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -84,11 +77,19 @@ export const RunCommand = cmd({
type: "string", type: "string",
describe: "title for the session (uses truncated prompt if no value provided)", 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) => { handler: async (args) => {
let message = args.message.join(" ") let message = args.message.join(" ")
let fileParts: any[] = [] const fileParts: any[] = []
if (args.file) { if (args.file) {
const files = Array.isArray(args.file) ? args.file : [args.file] const files = Array.isArray(args.file) ? args.file : [args.file]
@@ -124,79 +125,8 @@ export const RunCommand = cmd({
process.exit(1) process.exit(1)
} }
await bootstrap(process.cwd(), async () => { const execute = async (sdk: OpencodeClient, sessionID: string) => {
if (args.command) { const printEvent = (color: string, type: string, title: string) => {
const exists = await Command.get(args.command)
if (!exists) {
UI.error(`Command "${args.command}" not found`)
process.exit(1)
}
}
const session = await (async () => {
if (args.continue) {
const it = Session.list()
try {
for await (const s of it) {
if (s.parentID === undefined) {
return s
}
}
return
} finally {
await it.return()
}
}
if (args.session) return Session.get(args.session)
const title = (() => {
if (args.title !== undefined) {
if (args.title === "") {
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
return args.title
}
return undefined
})()
return Session.create({
title,
})
})()
if (!session) {
UI.error("Session not found")
process.exit(1)
}
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
}
}
}
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( UI.println(
color + `|`, color + `|`,
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
@@ -205,86 +135,74 @@ export const RunCommand = cmd({
) )
} }
function outputJsonEvent(type: string, data: any) { const outputJsonEvent = (type: string, data: any) => {
if (args.format === "json") { if (args.format === "json") {
const jsonEvent = { process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
type,
timestamp: Date.now(),
sessionID: session?.id,
...data,
}
process.stdout.write(JSON.stringify(jsonEvent) + EOL)
return true return true
} }
return false return false
} }
const messageID = Identifier.ascending("message") const events = await sdk.event.subscribe()
let errorMsg: string | undefined
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { const eventProcessor = (async () => {
if (evt.properties.part.sessionID !== session.id) return for await (const event of events.stream) {
if (evt.properties.part.messageID === messageID) return if (event.type === "message.part.updated") {
const part = evt.properties.part const part = event.properties.part
if (part.sessionID !== sessionID) continue
if (part.type === "tool" && part.state.status === "completed") { if (part.type === "tool" && part.state.status === "completed") {
if (outputJsonEvent("tool_use", { part })) return if (outputJsonEvent("tool_use", { part })) continue
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title = const title =
part.state.title || part.state.title ||
(Object.keys(part.state.input).length > 0 (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
? JSON.stringify(part.state.input)
: "Unknown")
printEvent(color, tool, title) printEvent(color, tool, title)
if (part.tool === "bash" && part.state.output?.trim()) {
if (part.tool === "bash" && part.state.output && part.state.output.trim()) {
UI.println() UI.println()
UI.println(part.state.output) UI.println(part.state.output)
} }
} }
if (part.type === "step-start") { if (part.type === "step-start") {
if (outputJsonEvent("step_start", { part })) return if (outputJsonEvent("step_start", { part })) continue
} }
if (part.type === "step-finish") { if (part.type === "step-finish") {
if (outputJsonEvent("step_finish", { part })) return if (outputJsonEvent("step_finish", { part })) continue
} }
if (part.type === "text") { if (part.type === "text" && part.time?.end) {
const text = part.text if (outputJsonEvent("text", { part })) continue
const isPiped = !process.stdout.isTTY const isPiped = !process.stdout.isTTY
if (part.time?.end) {
if (outputJsonEvent("text", { part })) return
if (!isPiped) UI.println() if (!isPiped) UI.println()
process.stdout.write((isPiped ? text : UI.markdown(text)) + EOL) process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
if (!isPiped) UI.println() if (!isPiped) UI.println()
} }
} }
})
let errorMsg: string | undefined if (event.type === "session.error") {
Bus.subscribe(Session.Event.Error, async (evt) => { const props = event.properties
const { sessionID, error } = evt.properties if (props.sessionID !== sessionID || !props.error) continue
if (sessionID !== session.id || !error) return let err = String(props.error.name)
let err = String(error.name) if ("data" in props.error && props.error.data && "message" in props.error.data) {
err = String(props.error.data.message)
if ("data" in error && error.data && "message" in error.data) {
err = error.data.message
} }
errorMsg = errorMsg ? errorMsg + EOL + err : err errorMsg = errorMsg ? errorMsg + EOL + err : err
if (outputJsonEvent("error", { error: props.error })) continue
if (outputJsonEvent("error", { error })) return
UI.error(err) UI.error(err)
}) }
Bus.subscribe(Permission.Event.Updated, async (evt) => { if (event.type === "session.idle" && event.properties.sessionID === sessionID) {
const permission = evt.properties break
const message = `Permission required to run: ${permission.title}` }
if (event.type === "permission.updated") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const result = await select({ const result = await select({
message, message: `Permission required to run: ${permission.title}`,
options: [ options: [
{ value: "once", label: "Allow once" }, { value: "once", label: "Allow once" },
{ value: "always", label: "Always allow" }, { value: "always", label: "Always allow" },
@@ -296,44 +214,139 @@ export const RunCommand = cmd({
| "once" | "once"
| "always" | "always"
| "reject" | "reject"
await sdk.postSessionIdPermissionsPermissionId({
Permission.respond({ path: { id: sessionID, permissionID: permission.id },
sessionID: session.id, body: { response },
permissionID: permission.id,
response,
})
})
await (async () => {
if (args.command) {
return await SessionPrompt.command({
messageID,
sessionID: session.id,
agent: agent.name,
model: providerID + "/" + modelID,
command: args.command,
arguments: message,
}) })
} }
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 (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 }],
},
})
}
await eventProcessor
if (errorMsg) process.exit(1) if (errorMsg) process.exit(1)
}
if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach })
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
const title =
args.title !== undefined
? args.title === ""
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
: args.title
: undefined
const result = await sdk.session.create({ body: title ? { title } : {} })
return result.data?.id
})()
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))
}
}
return await execute(sdk, sessionID)
}
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 (args.command) {
const exists = await Command.get(args.command)
if (!exists) {
server.stop()
UI.error(`Command "${args.command}" not found`)
process.exit(1)
}
}
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
const title =
args.title !== undefined
? args.title === ""
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
: args.title
: undefined
const result = await sdk.session.create({ body: title ? { title } : {} })
return result.data?.id
})()
if (!sessionID) {
server.stop()
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))
}
}
await execute(sdk, sessionID)
server.stop()
}) })
}, },
}) })

View File

@@ -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 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 #### Flags
| Flag | Short | Description | | Flag | Short | Description |
@@ -197,6 +207,8 @@ opencode run Explain the use of context in Go
| `--file` | `-f` | File(s) to attach to message | | `--file` | `-f` | File(s) to attach to message |
| `--format` | | Format: default (formatted) or json (raw JSON events) | | `--format` | | Format: default (formatted) or json (raw JSON events) |
| `--title` | | Title for the session (uses truncated prompt if no value provided) | | `--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) |
--- ---