thread.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { cmd } from "@/cli/cmd/cmd"
  2. import { tui } from "./app"
  3. import { Rpc } from "@/util/rpc"
  4. import { type rpc } from "./worker"
  5. import path from "path"
  6. import { fileURLToPath } from "url"
  7. import { UI } from "@/cli/ui"
  8. import { Log } from "@/util/log"
  9. import { withTimeout } from "@/util/timeout"
  10. import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
  11. import { Filesystem } from "@/util/filesystem"
  12. import type { Event } from "@opencode-ai/sdk/v2"
  13. import type { EventSource } from "./context/sdk"
  14. import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
  15. import { TuiConfig } from "@/config/tui"
  16. import { Instance } from "@/project/instance"
  17. declare global {
  18. const OPENCODE_WORKER_PATH: string
  19. }
  20. type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
  21. function createWorkerFetch(client: RpcClient): typeof fetch {
  22. const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
  23. const request = new Request(input, init)
  24. const body = request.body ? await request.text() : undefined
  25. const result = await client.call("fetch", {
  26. url: request.url,
  27. method: request.method,
  28. headers: Object.fromEntries(request.headers.entries()),
  29. body,
  30. })
  31. return new Response(result.body, {
  32. status: result.status,
  33. headers: result.headers,
  34. })
  35. }
  36. return fn as typeof fetch
  37. }
  38. function createEventSource(client: RpcClient): EventSource {
  39. return {
  40. on: (handler) => client.on<Event>("event", handler),
  41. }
  42. }
  43. async function target() {
  44. if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
  45. const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
  46. if (await Filesystem.exists(fileURLToPath(dist))) return dist
  47. return new URL("./worker.ts", import.meta.url)
  48. }
  49. async function input(value?: string) {
  50. const piped = process.stdin.isTTY
  51. ? undefined
  52. : await new Promise<string>((resolve) => {
  53. const chunks: Buffer[] = []
  54. process.stdin.on("data", (chunk) => chunks.push(chunk))
  55. process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")))
  56. })
  57. if (!value) return piped
  58. if (!piped) return value
  59. return piped + "\n" + value
  60. }
  61. export const TuiThreadCommand = cmd({
  62. command: "$0 [project]",
  63. describe: "start opencode tui",
  64. builder: (yargs) =>
  65. withNetworkOptions(yargs)
  66. .positional("project", {
  67. type: "string",
  68. describe: "path to start opencode in",
  69. })
  70. .option("model", {
  71. type: "string",
  72. alias: ["m"],
  73. describe: "model to use in the format of provider/model",
  74. })
  75. .option("continue", {
  76. alias: ["c"],
  77. describe: "continue the last session",
  78. type: "boolean",
  79. })
  80. .option("session", {
  81. alias: ["s"],
  82. type: "string",
  83. describe: "session id to continue",
  84. })
  85. .option("fork", {
  86. type: "boolean",
  87. describe: "fork the session when continuing (use with --continue or --session)",
  88. })
  89. .option("prompt", {
  90. type: "string",
  91. describe: "prompt to use",
  92. })
  93. .option("agent", {
  94. type: "string",
  95. describe: "agent to use",
  96. }),
  97. handler: async (args) => {
  98. // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
  99. // (Important when running under `bun run` wrappers on Windows.)
  100. const unguard = win32InstallCtrlCGuard()
  101. try {
  102. // Must be the very first thing — disables CTRL_C_EVENT before any Worker
  103. // spawn or async work so the OS cannot kill the process group.
  104. win32DisableProcessedInput()
  105. if (args.fork && !args.continue && !args.session) {
  106. UI.error("--fork requires --continue or --session")
  107. process.exitCode = 1
  108. return
  109. }
  110. // Resolve relative paths against PWD to preserve behavior when using --cwd flag
  111. const root = process.env.PWD ?? process.cwd()
  112. const cwd = args.project ? path.resolve(root, args.project) : process.cwd()
  113. const file = await target()
  114. try {
  115. process.chdir(cwd)
  116. } catch {
  117. UI.error("Failed to change directory to " + cwd)
  118. return
  119. }
  120. const worker = new Worker(file, {
  121. env: Object.fromEntries(
  122. Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
  123. ),
  124. })
  125. worker.onerror = (e) => {
  126. Log.Default.error(e)
  127. }
  128. const client = Rpc.client<typeof rpc>(worker)
  129. const error = (e: unknown) => {
  130. Log.Default.error(e)
  131. }
  132. const reload = () => {
  133. client.call("reload", undefined).catch((err) => {
  134. Log.Default.warn("worker reload failed", {
  135. error: err instanceof Error ? err.message : String(err),
  136. })
  137. })
  138. }
  139. process.on("uncaughtException", error)
  140. process.on("unhandledRejection", error)
  141. process.on("SIGUSR2", reload)
  142. let stopped = false
  143. const stop = async () => {
  144. if (stopped) return
  145. stopped = true
  146. process.off("uncaughtException", error)
  147. process.off("unhandledRejection", error)
  148. process.off("SIGUSR2", reload)
  149. await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
  150. Log.Default.warn("worker shutdown failed", {
  151. error: error instanceof Error ? error.message : String(error),
  152. })
  153. })
  154. worker.terminate()
  155. }
  156. const prompt = await input(args.prompt)
  157. const config = await Instance.provide({
  158. directory: cwd,
  159. fn: () => TuiConfig.get(),
  160. })
  161. const network = await resolveNetworkOptions(args)
  162. const external =
  163. process.argv.includes("--port") ||
  164. process.argv.includes("--hostname") ||
  165. process.argv.includes("--mdns") ||
  166. network.mdns ||
  167. network.port !== 0 ||
  168. network.hostname !== "127.0.0.1"
  169. const transport = external
  170. ? {
  171. url: (await client.call("server", network)).url,
  172. fetch: undefined,
  173. events: undefined,
  174. }
  175. : {
  176. url: "http://opencode.internal",
  177. fetch: createWorkerFetch(client),
  178. events: createEventSource(client),
  179. }
  180. setTimeout(() => {
  181. client.call("checkUpgrade", { directory: cwd }).catch(() => {})
  182. }, 1000).unref?.()
  183. try {
  184. await tui({
  185. url: transport.url,
  186. config,
  187. directory: cwd,
  188. fetch: transport.fetch,
  189. events: transport.events,
  190. args: {
  191. continue: args.continue,
  192. sessionID: args.session,
  193. agent: args.agent,
  194. model: args.model,
  195. prompt,
  196. fork: args.fork,
  197. },
  198. onExit: stop,
  199. })
  200. } finally {
  201. await stop()
  202. }
  203. } finally {
  204. unguard?.()
  205. }
  206. process.exit(0)
  207. },
  208. })