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 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,79 +125,8 @@ 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 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) {
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, " ")}`,
@@ -205,135 +135,218 @@ export const RunCommand = cmd({
)
}
function outputJsonEvent(type: string, data: any) {
const 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)
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true
}
return false
}
const messageID = Identifier.ascending("message")
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()
}
}
})
const events = await sdk.event.subscribe()
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) {
err = error.data.message
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)
}
}
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
UI.error(err)
})
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,
})
})
await (async () => {
if (args.command) {
return await SessionPrompt.command({
messageID,
sessionID: session.id,
agent: agent.name,
model: providerID + "/" + modelID,
if (args.command) {
await sdk.session.command({
path: { id: sessionID },
body: {
agent: args.agent || "build",
model: args.model,
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,
},
],
})
})()
} 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 (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()
})
},
})