Compare commits

..
Author SHA1 Message Date
Dax Raad 0023dd0813 ci: remove heavy nix-desktop workflow 2026-01-29 14:10:11 -05:00
Dax Raad 8787b527d4 ci: remove push triggers from workflow files 2026-01-29 14:06:15 -05:00
14 changed files with 563 additions and 778 deletions
-40
View File
@@ -1,40 +0,0 @@
name: beta
on:
push:
branches: [dev]
pull_request:
types: [opened, synchronize, labeled, unlabeled]
jobs:
sync:
if: |
github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'contributor'))
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: read
actions: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Sync beta branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bun script/beta.ts
- name: Trigger publish workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run publish.yml --ref beta
-1
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- dev
pull_request:
workflow_dispatch:
jobs:
@@ -1,46 +0,0 @@
name: nix-desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
workflow_dispatch:
jobs:
nix-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404-arm
- macos-15-intel
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L
-1
View File
@@ -6,7 +6,6 @@ on:
branches:
- ci
- dev
- beta
- snapshot-*
workflow_dispatch:
inputs:
+487 -329
View File
File diff suppressed because it is too large Load Diff
@@ -130,7 +130,7 @@ export function SessionHeader() {
<Portal mount={mount()}>
<button
type="button"
class="hidden md:flex w-[320px] max-w-full min-w-0 p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
+12 -23
View File
@@ -132,14 +132,12 @@ export function Titlebar() {
}
return (
<header
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
data-tauri-drag-region
>
<header class="h-10 shrink-0 bg-background-base flex items-center relative" data-tauri-drag-region>
<div
classList={{
"flex items-center min-w-0": true,
"flex items-center w-full min-w-0": true,
"pl-2": !mac(),
"pr-6": !windows(),
}}
onMouseDown={drag}
data-tauri-drag-region
@@ -220,29 +218,20 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
</div>
<div
class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"
data-tauri-drag-region
>
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-6": !windows(),
}}
onMouseDown={drag}
data-tauri-drag-region
>
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" data-tauri-drag-region />
<div class="flex-1 h-full" data-tauri-drag-region />
<div
id="opencode-titlebar-right"
class="flex items-center gap-3 shrink-0 flex-1 justify-end"
data-tauri-drag-region
/>
<Show when={windows()}>
<div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto" />
</div>
</header>
)
}
+1 -1
View File
@@ -49,7 +49,7 @@
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.13.0",
"@agentclientprotocol/sdk": "0.12.0",
"@ai-sdk/amazon-bedrock": "3.0.73",
"@ai-sdk/anthropic": "2.0.57",
"@ai-sdk/azure": "2.0.91",
+56 -186
View File
@@ -26,7 +26,6 @@ import {
type ToolCallContent,
type ToolKind,
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
@@ -41,11 +40,6 @@ import { LoadAPIKeyError } from "ai"
import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
const DEFAULT_VARIANT_VALUE = "default"
export namespace ACP {
const log = Log.create({ service: "acp-agent" })
@@ -482,7 +476,7 @@ export namespace ACP {
sessionId,
models: load.models,
modes: load.modes,
_meta: load._meta,
_meta: {},
}
} catch (e) {
const error = MessageV2.fromError(e, {
@@ -535,7 +529,7 @@ export namespace ACP {
providerID: lastUser.model.providerID,
modelID: lastUser.model.modelID,
})
if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) {
if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) {
result.modes.currentModeId = lastUser.agent
this.sessionManager.setMode(sessionId, lastUser.agent)
}
@@ -962,7 +956,27 @@ export namespace ACP {
}
}
private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
private async loadSessionMode(params: LoadSessionRequest) {
const directory = params.cwd
const model = await defaultModel(this.config, directory)
const sessionId = params.sessionId
const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
const entries = providers.sort((a, b) => {
const nameA = a.name.toLowerCase()
const nameB = b.name.toLowerCase()
if (nameA < nameB) return -1
if (nameA > nameB) return 1
return 0
})
const availableModels = entries.flatMap((provider) => {
const models = Provider.sort(Object.values(provider.models))
return models.map((model) => ({
modelId: `${provider.id}/${model.id}`,
name: `${provider.name}/${model.name}`,
}))
})
const agents = await this.config.sdk.app
.agents(
{
@@ -972,56 +986,6 @@ export namespace ACP {
)
.then((resp) => resp.data!)
return agents
.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
.map((agent) => ({
id: agent.name,
name: agent.name,
description: agent.description,
}))
}
private async resolveModeState(
directory: string,
sessionId: string,
): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
const availableModes = await this.loadAvailableModes(directory)
const currentModeId =
this.sessionManager.get(sessionId).modeId ||
(await (async () => {
if (!availableModes.length) return undefined
const defaultAgentName = await AgentModule.defaultAgent()
const resolvedModeId =
availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
})())
return { availableModes, currentModeId }
}
private async loadSessionMode(params: LoadSessionRequest) {
const directory = params.cwd
const model = await defaultModel(this.config, directory)
const sessionId = params.sessionId
const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
const entries = sortProvidersByName(providers)
const availableVariants = modelVariantsFromProviders(entries, model)
const currentVariant = this.sessionManager.getVariant(sessionId)
if (currentVariant && !availableVariants.includes(currentVariant)) {
this.sessionManager.setVariant(sessionId, undefined)
}
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const modeState = await this.resolveModeState(directory, sessionId)
const currentModeId = modeState.currentModeId
const modes = currentModeId
? {
availableModes: modeState.availableModes,
currentModeId,
}
: undefined
const commands = await this.config.sdk.command
.list(
{
@@ -1042,6 +1006,20 @@ export namespace ACP {
description: "compact the session",
})
const availableModes = agents
.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
.map((agent) => ({
id: agent.name,
name: agent.name,
description: agent.description,
}))
const defaultAgentName = await AgentModule.defaultAgent()
const currentModeId = availableModes.find((m) => m.name === defaultAgentName)?.id ?? availableModes[0].id
// Persist the default mode so prompt() uses it immediately
this.sessionManager.setMode(sessionId, currentModeId)
const mcpServers: Record<string, Config.Mcp> = {}
for (const server of params.mcpServers) {
if ("type" in server) {
@@ -1095,46 +1073,40 @@ export namespace ACP {
return {
sessionId,
models: {
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
currentModelId: `${model.providerID}/${model.modelID}`,
availableModels,
},
modes,
_meta: buildVariantMeta({
model,
variant: this.sessionManager.getVariant(sessionId),
availableVariants,
}),
modes: {
availableModes,
currentModeId,
},
_meta: {},
}
}
async unstable_setSessionModel(params: SetSessionModelRequest) {
const session = this.sessionManager.get(params.sessionId)
const providers = await this.sdk.config
.providers({ directory: session.cwd }, { throwOnError: true })
.then((x) => x.data!.providers)
const selection = parseModelSelection(params.modelId, providers)
this.sessionManager.setModel(session.id, selection.model)
this.sessionManager.setVariant(session.id, selection.variant)
const model = Provider.parseModel(params.modelId)
const entries = sortProvidersByName(providers)
const availableVariants = modelVariantsFromProviders(entries, selection.model)
this.sessionManager.setModel(session.id, {
providerID: model.providerID,
modelID: model.modelID,
})
return {
_meta: buildVariantMeta({
model: selection.model,
variant: selection.variant,
availableVariants,
}),
_meta: {},
}
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
const session = this.sessionManager.get(params.sessionId)
const availableModes = await this.loadAvailableModes(session.cwd)
if (!availableModes.some((mode) => mode.id === params.modeId)) {
throw new Error(`Agent not found: ${params.modeId}`)
}
this.sessionManager.get(params.sessionId)
await this.config.sdk.app
.agents({}, { throwOnError: true })
.then((x) => x.data)
.then((agent) => {
if (!agent) throw new Error(`Agent not found: ${params.modeId}`)
})
this.sessionManager.setMode(params.sessionId, params.modeId)
}
@@ -1251,7 +1223,6 @@ export namespace ACP {
providerID: model.providerID,
modelID: model.modelID,
},
variant: this.sessionManager.getVariant(sessionID),
parts,
agent,
directory,
@@ -1463,105 +1434,4 @@ export namespace ACP {
}
return result
}
function sortProvidersByName<T extends { name: string }>(providers: T[]): T[] {
return [...providers].sort((a, b) => {
const nameA = a.name.toLowerCase()
const nameB = b.name.toLowerCase()
if (nameA < nameB) return -1
if (nameA > nameB) return 1
return 0
})
}
function modelVariantsFromProviders(
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
model: { providerID: string; modelID: string },
): string[] {
const provider = providers.find((entry) => entry.id === model.providerID)
if (!provider) return []
const modelInfo = provider.models[model.modelID]
if (!modelInfo?.variants) return []
return Object.keys(modelInfo.variants)
}
function buildAvailableModels(
providers: Array<{ id: string; name: string; models: Record<string, any> }>,
options: { includeVariants?: boolean } = {},
): ModelOption[] {
const includeVariants = options.includeVariants ?? false
return providers.flatMap((provider) => {
const models = Provider.sort(Object.values(provider.models) as any)
return models.flatMap((model) => {
const base: ModelOption = {
modelId: `${provider.id}/${model.id}`,
name: `${provider.name}/${model.name}`,
}
if (!includeVariants || !model.variants) return [base]
const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE)
const variantOptions = variants.map((variant) => ({
modelId: `${provider.id}/${model.id}/${variant}`,
name: `${provider.name}/${model.name} (${variant})`,
}))
return [base, ...variantOptions]
})
})
}
function formatModelIdWithVariant(
model: { providerID: string; modelID: string },
variant: string | undefined,
availableVariants: string[],
includeVariant: boolean,
) {
const base = `${model.providerID}/${model.modelID}`
if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
return `${base}/${variant}`
}
function buildVariantMeta(input: {
model: { providerID: string; modelID: string }
variant?: string
availableVariants: string[]
}) {
return {
opencode: {
modelId: `${input.model.providerID}/${input.model.modelID}`,
variant: input.variant ?? null,
availableVariants: input.availableVariants,
},
}
}
function parseModelSelection(
modelId: string,
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
): { model: { providerID: string; modelID: string }; variant?: string } {
const parsed = Provider.parseModel(modelId)
const provider = providers.find((p) => p.id === parsed.providerID)
if (!provider) {
return { model: parsed, variant: undefined }
}
// Check if modelID exists directly
if (provider.models[parsed.modelID]) {
return { model: parsed, variant: undefined }
}
// Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high")
const segments = parsed.modelID.split("/")
if (segments.length > 1) {
const candidateVariant = segments[segments.length - 1]
const baseModelId = segments.slice(0, -1).join("/")
const baseModelInfo = provider.models[baseModelId]
if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) {
return {
model: { providerID: parsed.providerID, modelID: baseModelId },
variant: candidateVariant,
}
}
}
return { model: parsed, variant: undefined }
}
}
-12
View File
@@ -96,18 +96,6 @@ export class ACPSessionManager {
return session
}
getVariant(sessionId: string) {
const session = this.get(sessionId)
return session.variant
}
setVariant(sessionId: string, variant?: string) {
const session = this.get(sessionId)
session.variant = variant
this.sessions.set(sessionId, session)
return session
}
setMode(sessionId: string, modeId: string) {
const session = this.get(sessionId)
session.modeId = modeId
-1
View File
@@ -10,7 +10,6 @@ export interface ACPSessionState {
providerID: string
modelID: string
}
variant?: string
modeId?: string
}
@@ -158,7 +158,7 @@ export function DialogModel(props: { providerID?: string }) {
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inFavorites) return false
const inRecents = recents.some(
const inRecents = recentList.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inRecents) return false
+5 -9
View File
@@ -87,7 +87,6 @@ You can also access our models through the following API endpoints.
| GLM 4.7 Free | glm-4.7-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.5 Free | kimi-k2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -121,8 +120,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| GLM 4.7 Free | Free | Free | Free | - |
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2.5 Free | Free | Free | Free | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
@@ -155,9 +153,8 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don
The free models:
- GLM 4.7 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Kimi M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- MiniMax M2.1 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- GLM 4.7 is currently free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- MiniMax M2.1 is currently free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
<a href={email}>Contact us</a> if you have any questions.
@@ -187,10 +184,9 @@ charging you more than $20 if your balance goes below $5.
All our models are hosted in the US. Our providers follow a zero-retention policy and do not use your data for model training, with the following exceptions:
- GLM 4.7: During its free period, collected data may be used to improve the model.
- MiniMax M2.1: During its free period, collected data may be used to improve the model.
- Big Pickle: During its free period, collected data may be used to improve the model.
- GLM 4.7 Free: During its free period, collected data may be used to improve the model.
- Kimi K2.5 Free: During its free period, collected data may be used to improve the model.
- MiniMax M2.1 Free: During its free period, collected data may be used to improve the model.
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env bun
interface PR {
number: number
headRefName: string
headRefOid: string
createdAt: string
isDraft: boolean
title: string
}
async function main() {
console.log("Fetching open contributor PRs...")
const prsResult =
await $`gh pr list --label contributor --state open --json number,headRefName,headRefOid,createdAt,isDraft,title --limit 100`.nothrow()
if (prsResult.exitCode !== 0) {
throw new Error(`Failed to fetch PRs: ${prsResult.stderr}`)
}
const allPRs: PR[] = JSON.parse(prsResult.stdout)
const prs = allPRs.filter((pr) => !pr.isDraft)
console.log(`Found ${prs.length} open non-draft contributor PRs`)
console.log("Fetching latest dev branch...")
const fetchDev = await $`git fetch origin dev`.nothrow()
if (fetchDev.exitCode !== 0) {
throw new Error(`Failed to fetch dev branch: ${fetchDev.stderr}`)
}
console.log("Checking out beta branch...")
const checkoutBeta = await $`git checkout -B beta origin/dev`.nothrow()
if (checkoutBeta.exitCode !== 0) {
throw new Error(`Failed to checkout beta branch: ${checkoutBeta.stderr}`)
}
const applied: number[] = []
const skipped: Array<{ number: number; reason: string }> = []
for (const pr of prs) {
console.log(`\nProcessing PR #${pr.number}: ${pr.title}`)
const fetchPR = await $`git fetch origin pull/${pr.number}/head:pr-${pr.number}`.nothrow()
if (fetchPR.exitCode !== 0) {
console.log(` Failed to fetch PR #${pr.number}, skipping`)
skipped.push({ number: pr.number, reason: "Failed to fetch" })
continue
}
const merge = await $`git merge --squash pr-${pr.number}`.nothrow()
if (merge.exitCode !== 0) {
console.log(` Squash merge failed for PR #${pr.number}`)
console.log(` Error: ${merge.stderr}`)
await $`git reset --hard HEAD`.nothrow()
skipped.push({ number: pr.number, reason: `Squash merge failed: ${merge.stderr}` })
continue
}
const add = await $`git add -A`.nothrow()
if (add.exitCode !== 0) {
console.log(` Failed to stage changes for PR #${pr.number}`)
await $`git reset --hard HEAD`.nothrow()
skipped.push({ number: pr.number, reason: "Failed to stage" })
continue
}
const status = await $`git status --porcelain`.nothrow()
if (status.exitCode !== 0 || !status.stdout.trim()) {
console.log(` No changes to commit for PR #${pr.number}, skipping`)
await $`git reset --hard HEAD`.nothrow()
skipped.push({ number: pr.number, reason: "No changes to commit" })
continue
}
const commitMsg = `Apply PR #${pr.number}: ${pr.title}`
const commit = await Bun.spawn(["git", "commit", "-m", commitMsg], { stdout: "pipe", stderr: "pipe" })
const commitExit = await commit.exited
const commitStderr = await Bun.readableStreamToText(commit.stderr)
if (commitExit !== 0) {
console.log(` Failed to commit PR #${pr.number}`)
console.log(` Error: ${commitStderr}`)
await $`git reset --hard HEAD`.nothrow()
skipped.push({ number: pr.number, reason: `Commit failed: ${commitStderr}` })
continue
}
console.log(` Successfully applied PR #${pr.number}`)
applied.push(pr.number)
}
console.log("\n--- Summary ---")
console.log(`Applied: ${applied.length} PRs`)
applied.forEach((num) => console.log(` - PR #${num}`))
console.log(`Skipped: ${skipped.length} PRs`)
skipped.forEach((x) => console.log(` - PR #${x.number}: ${x.reason}`))
console.log("\nForce pushing beta branch...")
const push = await $`git push origin beta --force`.nothrow()
if (push.exitCode !== 0) {
throw new Error(`Failed to push beta branch: ${push.stderr}`)
}
console.log("Successfully synced beta branch")
}
main().catch((err) => {
console.error("Error:", err)
process.exit(1)
})
function $(strings: TemplateStringsArray, ...values: unknown[]) {
const cmd = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "")
return {
async nothrow() {
const proc = Bun.spawn(cmd.split(" "), {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
return { exitCode, stdout, stderr }
},
}
}