server-compat.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import type { ServerApi } from "./server"
  2. import type { ServerProtocol } from "./server-protocol"
  3. import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client"
  4. import type {
  5. Project,
  6. ProjectCurrent,
  7. SessionApi,
  8. SessionCommandInput,
  9. SessionCommandOutput,
  10. SessionCompactInput,
  11. SessionCompactOutput,
  12. SessionInfo,
  13. SessionPromptInput,
  14. SessionPromptOutput,
  15. SessionShellInput,
  16. SessionShellOutput,
  17. } from "@opencode-ai/client/promise"
  18. type LegacyClient = OpencodeClient
  19. type LegacyFor = (directory?: string) => LegacyClient
  20. type CompatibleSessionApi = Omit<
  21. SessionApi,
  22. "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
  23. > & {
  24. prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
  25. command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
  26. shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
  27. compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
  28. rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
  29. // archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
  30. remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
  31. }
  32. type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
  33. reply: (
  34. input: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } },
  35. ) => ReturnType<ServerApi["permission"]["reply"]>
  36. }
  37. export type CompatibleApi = Omit<ServerApi, "session" | "permission"> & {
  38. readonly session: CompatibleSessionApi
  39. readonly permission: CompatiblePermissionApi
  40. }
  41. type LegacyPrompt = {
  42. agent?: string
  43. model?: { providerID: string; modelID: string }
  44. variant?: string
  45. legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[]
  46. }
  47. type LegacyLocation = { directory?: string }
  48. type CompatibleInput = {
  49. protocol: Promise<ServerProtocol>
  50. current: ServerApi
  51. legacy: LegacyFor
  52. directory?: string
  53. }
  54. function mime(uri: string) {
  55. const match = /^data:([^;,]+)/.exec(uri)
  56. return match?.[1] ?? "application/octet-stream"
  57. }
  58. function sessionInfo(session: Session): SessionInfo {
  59. return {
  60. id: session.id,
  61. parentID: session.parentID,
  62. projectID: session.projectID,
  63. agent: session.agent,
  64. model: session.model && {
  65. id: session.model.id,
  66. providerID: session.model.providerID,
  67. variant: session.model.variant,
  68. },
  69. cost: session.cost ?? 0,
  70. tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  71. time: session.time,
  72. title: session.title,
  73. location: { directory: session.directory, workspaceID: session.workspaceID },
  74. subpath: session.path,
  75. revert: session.revert && {
  76. messageID: session.revert.messageID,
  77. partID: session.revert.partID,
  78. snapshot: session.revert.snapshot,
  79. },
  80. }
  81. }
  82. export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
  83. const v1 = createV1Api(input)
  84. return lazyApi(
  85. input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
  86. input.current,
  87. )
  88. }
  89. function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
  90. const cache = new Map<PropertyKey, unknown>()
  91. return new Proxy(shape, {
  92. get(target, property, receiver) {
  93. const sample = Reflect.get(target, property, receiver)
  94. if (typeof sample === "function") {
  95. return (...args: unknown[]) =>
  96. implementation.then((value) => {
  97. const method = Reflect.get(value, property)
  98. if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
  99. return Reflect.apply(method, value, args)
  100. })
  101. }
  102. if (sample === null || typeof sample !== "object") return sample
  103. if (cache.has(property)) return cache.get(property)
  104. const nested = lazyApi(
  105. implementation.then((value) => {
  106. const result = Reflect.get(value, property)
  107. if (result === null || typeof result !== "object") {
  108. throw new Error(`API namespace unavailable: ${String(property)}`)
  109. }
  110. return result
  111. }),
  112. sample,
  113. )
  114. cache.set(property, nested)
  115. return nested
  116. },
  117. })
  118. }
  119. function createV1Api(input: CompatibleInput): CompatibleApi {
  120. const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
  121. const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
  122. const located = <T>(data: T, value?: { directory?: string }) => ({
  123. location: {
  124. directory: directory(value) ?? "",
  125. project: { id: "", directory: directory(value) ?? "" },
  126. },
  127. data,
  128. })
  129. return {
  130. ...input.current,
  131. session: {
  132. ...input.current.session,
  133. async list(
  134. value?: Parameters<ServerApi["session"]["list"]>[0],
  135. options?: Parameters<ServerApi["session"]["list"]>[1],
  136. ) {
  137. if (!value?.directory && value?.search !== undefined) {
  138. const result = await legacy().experimental.session.list(
  139. {
  140. roots: value.parentID === null ? true : undefined,
  141. search: value.search,
  142. limit: value.limit,
  143. },
  144. options,
  145. )
  146. return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
  147. }
  148. const result = await legacy({ directory: value?.directory }).session.list({
  149. directory: value?.directory,
  150. roots: value?.parentID === null ? true : undefined,
  151. search: value?.search,
  152. limit: value?.limit,
  153. })
  154. return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
  155. },
  156. async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
  157. const result = await legacy(value?.location ?? undefined).session.create({
  158. directory: directory(value?.location ?? undefined),
  159. })
  160. if (!result.data) throw new Error("Failed to create session")
  161. return sessionInfo(result.data)
  162. },
  163. async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
  164. const result = await legacy().session.get(value)
  165. if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
  166. return sessionInfo(result.data)
  167. },
  168. async active() {
  169. const result = await legacy().session.status()
  170. return Object.fromEntries(
  171. Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
  172. status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
  173. ),
  174. )
  175. },
  176. async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
  177. await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
  178. },
  179. // async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
  180. // await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
  181. // },
  182. async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
  183. await legacy(value).session.delete(value)
  184. },
  185. async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
  186. const result = await legacy().session.fork(value)
  187. if (!result.data) throw new Error("Failed to fork session")
  188. return sessionInfo(result.data)
  189. },
  190. async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
  191. await legacy().session.abort(value)
  192. },
  193. async prompt(value: SessionPromptInput & LegacyPrompt) {
  194. await legacy().session.promptAsync({
  195. sessionID: value.sessionID,
  196. messageID: value.id ?? undefined,
  197. agent: value.agent,
  198. model: value.model,
  199. variant: value.variant,
  200. parts: value.legacyParts ?? [
  201. { type: "text", text: value.text },
  202. ...(value.files ?? []).map((file) => ({
  203. type: "file" as const,
  204. mime: file.mention ? "text/plain" : mime(file.uri),
  205. url: file.uri,
  206. filename: file.name,
  207. source: file.mention
  208. ? {
  209. type: "file" as const,
  210. text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
  211. path: file.uri,
  212. }
  213. : undefined,
  214. })),
  215. ...(value.agents ?? []).map((agent) => ({
  216. type: "agent" as const,
  217. name: agent.name,
  218. source: agent.mention
  219. ? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
  220. : undefined,
  221. })),
  222. ],
  223. })
  224. return {
  225. admittedSeq: 0,
  226. id: value.id ?? "",
  227. sessionID: value.sessionID,
  228. timeCreated: Date.now(),
  229. type: "user",
  230. data: { text: value.text },
  231. delivery: value.delivery ?? "steer",
  232. }
  233. },
  234. async command(value: SessionCommandInput) {
  235. await legacy().session.command({
  236. sessionID: value.sessionID,
  237. messageID: value.id ?? undefined,
  238. command: value.command,
  239. arguments: value.arguments ?? "",
  240. agent: value.agent ?? undefined,
  241. model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
  242. variant: value.model?.variant,
  243. parts: value.files?.map((file) => ({
  244. type: "file" as const,
  245. mime: mime(file.uri),
  246. url: file.uri,
  247. filename: file.name,
  248. })),
  249. })
  250. return {
  251. admittedSeq: 0,
  252. id: value.id ?? "",
  253. sessionID: value.sessionID,
  254. timeCreated: Date.now(),
  255. type: "user",
  256. data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
  257. delivery: value.delivery ?? "steer",
  258. }
  259. },
  260. async shell(value: SessionShellInput & LegacyPrompt) {
  261. await legacy().session.shell({
  262. sessionID: value.sessionID,
  263. command: value.command,
  264. agent: value.agent,
  265. model: value.model,
  266. })
  267. },
  268. compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
  269. if (!value.model) throw new Error("A model is required to compact a V1 session")
  270. await legacy().session.summarize({
  271. sessionID: value.sessionID,
  272. providerID: value.model.providerID,
  273. modelID: value.model.modelID,
  274. })
  275. return {
  276. admittedSeq: 0,
  277. id: value.id ?? "",
  278. sessionID: value.sessionID,
  279. timeCreated: Date.now(),
  280. type: "compaction",
  281. }
  282. },
  283. revert: {
  284. stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
  285. await legacy().session.revert(value)
  286. return { messageID: value.messageID }
  287. },
  288. clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
  289. await legacy().session.unrevert(value)
  290. },
  291. commit: input.current.session.revert.commit,
  292. },
  293. },
  294. project: {
  295. ...input.current.project,
  296. async list() {
  297. return ((await legacy().project.list()).data ?? []) as Project[]
  298. },
  299. async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
  300. const result = await legacy(value?.location).project.current()
  301. if (!result.data) throw new Error("Project not found")
  302. return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
  303. },
  304. // async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
  305. // const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
  306. // const result = await legacy({ directory: project?.worktree }).project.update({
  307. // ...value,
  308. // directory: project?.worktree,
  309. // })
  310. // if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
  311. // return result.data as Project
  312. // },
  313. async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
  314. const result = await legacy(value.location).worktree.list()
  315. return (result.data ?? []).map((item) => ({ directory: item }))
  316. },
  317. },
  318. // path: {
  319. // ...input.current.path,
  320. // async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
  321. // const result = await legacy(value?.location).path.get()
  322. // if (!result.data) throw new Error("Path unavailable")
  323. // return result.data
  324. // },
  325. // },
  326. vcs: {
  327. ...input.current.vcs,
  328. // async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
  329. // const result = await legacy(value?.location).vcs.get()
  330. // return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
  331. // },
  332. async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
  333. const result = await legacy(value?.location).vcs.status()
  334. return located(result.data ?? [], value?.location)
  335. },
  336. async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
  337. const result = await legacy(value.location).vcs.diff({
  338. mode: value.mode === "working" ? "git" : value.mode,
  339. context: value.context,
  340. })
  341. return located(
  342. (result.data ?? []).map((file) => ({
  343. file: file.file,
  344. patch: file.patch ?? "",
  345. additions: file.additions,
  346. deletions: file.deletions,
  347. status: file.status ?? "modified",
  348. })),
  349. value.location,
  350. )
  351. },
  352. },
  353. file: {
  354. ...input.current.file,
  355. async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
  356. const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
  357. return located(result.data ?? [], value?.location)
  358. },
  359. async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
  360. const result = await legacy(value.location).find.files({
  361. query: value.query,
  362. dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false",
  363. limit: value.limit,
  364. })
  365. return located(
  366. (result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
  367. value.location,
  368. )
  369. },
  370. },
  371. integration: {
  372. ...input.current.integration,
  373. async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
  374. const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
  375. (method, index) =>
  376. method.type === "api"
  377. ? { type: "key" as const, label: method.label }
  378. : { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
  379. )
  380. return located(
  381. {
  382. id: value.integrationID,
  383. name: value.integrationID,
  384. methods,
  385. connections: [],
  386. },
  387. value.location,
  388. )
  389. },
  390. connect: {
  391. ...input.current.integration.connect,
  392. key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
  393. await legacy(value.location).auth.set({
  394. providerID: value.integrationID,
  395. auth: { type: "api", key: value.key },
  396. })
  397. await legacy(value.location).instance.dispose()
  398. await input.legacy().instance.dispose()
  399. },
  400. },
  401. oauth: {
  402. ...input.current.integration.oauth,
  403. connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
  404. const method = Number(value.methodID)
  405. const result = await legacy(value.location).provider.oauth.authorize(
  406. { providerID: value.integrationID, method, inputs: value.inputs },
  407. { throwOnError: true },
  408. )
  409. if (!result.data) throw new Error("Failed to start OAuth authorization")
  410. return located(
  411. {
  412. attemptID: `${value.integrationID}:${method}`,
  413. url: result.data.url,
  414. instructions: result.data.instructions,
  415. mode: result.data.method,
  416. time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
  417. },
  418. value.location,
  419. )
  420. },
  421. complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
  422. const method = Number(value.attemptID.split(":").at(-1))
  423. await legacy(value.location).provider.oauth.callback(
  424. { providerID: value.integrationID, method, code: value.code },
  425. { throwOnError: true },
  426. )
  427. await legacy(value.location).instance.dispose()
  428. await input.legacy().instance.dispose()
  429. },
  430. status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
  431. const method = Number(value.attemptID.split(":").at(-1))
  432. await legacy(value.location).provider.oauth.callback(
  433. { providerID: value.integrationID, method },
  434. { throwOnError: true },
  435. )
  436. await legacy(value.location).instance.dispose()
  437. await input.legacy().instance.dispose()
  438. return located(
  439. { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
  440. value.location,
  441. )
  442. },
  443. },
  444. },
  445. pty: {
  446. ...input.current.pty,
  447. // async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
  448. // return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
  449. // },
  450. async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
  451. return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
  452. },
  453. async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
  454. const result = await legacy(value?.location).pty.create({
  455. command: value?.command,
  456. args: value?.args ? [...value.args] : undefined,
  457. cwd: value?.cwd,
  458. title: value?.title,
  459. env: value?.env,
  460. })
  461. if (!result.data) throw new Error("Failed to create terminal")
  462. return located(result.data, value?.location)
  463. },
  464. async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
  465. const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
  466. if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
  467. return located(result.data, value.location)
  468. },
  469. async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
  470. const result = await legacy(value.location).pty.update({
  471. ptyID: value.ptyID,
  472. title: value.title,
  473. size: value.size,
  474. })
  475. if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
  476. return located(result.data, value.location)
  477. },
  478. async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
  479. await legacy(value.location).pty.remove({ ptyID: value.ptyID })
  480. },
  481. // async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
  482. // const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
  483. // if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
  484. // return located(result.data, value.location)
  485. // },
  486. },
  487. permission: {
  488. ...input.current.permission,
  489. async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
  490. await legacy(value.location).permission.respond({
  491. sessionID: value.sessionID,
  492. permissionID: value.requestID,
  493. response: value.reply,
  494. directory: directory(value.location),
  495. })
  496. },
  497. },
  498. question: {
  499. ...input.current.question,
  500. async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
  501. await legacy().question.reply({
  502. requestID: value.requestID,
  503. answers: value.answers.map((answer) => [...answer]),
  504. })
  505. },
  506. async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
  507. await legacy().question.reject({ requestID: value.requestID })
  508. },
  509. },
  510. }
  511. }