mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-23 02:34:21 +01:00
feat: add --attach flag to opencode run (#3889)
This commit is contained in:
@@ -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,135 +135,218 @@ 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()
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
printEvent(color, tool, title)
|
|
||||||
|
|
||||||
if (part.tool === "bash" && part.state.output && part.state.output.trim()) {
|
|
||||||
UI.println()
|
|
||||||
UI.println(part.state.output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "step-start") {
|
|
||||||
if (outputJsonEvent("step_start", { part })) return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "step-finish") {
|
|
||||||
if (outputJsonEvent("step_finish", { part })) return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "text") {
|
|
||||||
const text = part.text
|
|
||||||
const isPiped = !process.stdout.isTTY
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let errorMsg: string | undefined
|
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)
|
|
||||||
|
|
||||||
if ("data" in error && error.data && "message" in error.data) {
|
const eventProcessor = (async () => {
|
||||||
err = error.data.message
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "step-start") {
|
||||||
|
if (outputJsonEvent("step_start", { part })) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (event.type === "session.idle" && event.properties.sessionID === sessionID) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
errorMsg = errorMsg ? errorMsg + EOL + err : err
|
})()
|
||||||
|
|
||||||
if (outputJsonEvent("error", { error })) return
|
if (args.command) {
|
||||||
UI.error(err)
|
await sdk.session.command({
|
||||||
})
|
path: { id: sessionID },
|
||||||
|
body: {
|
||||||
Bus.subscribe(Permission.Event.Updated, async (evt) => {
|
agent: args.agent || "build",
|
||||||
const permission = evt.properties
|
model: args.model,
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await (async () => {
|
|
||||||
if (args.command) {
|
|
||||||
return await SessionPrompt.command({
|
|
||||||
messageID,
|
|
||||||
sessionID: session.id,
|
|
||||||
agent: agent.name,
|
|
||||||
model: providerID + "/" + modelID,
|
|
||||||
command: args.command,
|
command: args.command,
|
||||||
arguments: message,
|
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
})()
|
} 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()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user