Browse Source

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

monke-yo 3 months ago
parent
commit
ee8b81269b
2 changed files with 210 additions and 185 deletions
  1. 198 185
      packages/opencode/src/cli/cmd/run.ts
  2. 12 0
      packages/web/src/content/docs/cli.mdx

+ 198 - 185
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()
     })
   },
 })

+ 12 - 0
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)                |
 
 ---