Compare commits

..

1 Commits

Author SHA1 Message Date
Dax Raad
2beeccb39a perf(tool): reduce edit and read memory overhead 2026-03-24 16:19:38 -04:00
75 changed files with 1151 additions and 2706 deletions

View File

@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
deploy:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3

View File

@@ -56,7 +56,7 @@ jobs:
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}"

View File

@@ -1,21 +1,5 @@
---
model: opencode/kimi-k2.5
---
create UPCOMING_CHANGELOG.md
it should have sections
```
# TUI
# Desktop
# Core
# Misc
```
go through each PR merged since the last tag
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved

View File

@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

View File

@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -79,7 +79,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -113,7 +113,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -140,7 +140,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -164,7 +164,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -188,7 +188,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -221,7 +221,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -252,7 +252,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -281,7 +281,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -297,7 +297,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.2",
"version": "1.3.0",
"bin": {
"opencode": "./bin/opencode",
},
@@ -358,7 +358,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.2",
"gitlab-ai-provider": "5.2.2",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -370,7 +370,6 @@
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
@@ -422,7 +421,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -446,7 +445,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.2",
"version": "1.3.0",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -457,7 +456,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -492,7 +491,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -538,7 +537,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"zod": "catalog:",
},
@@ -549,7 +548,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -3037,7 +3036,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -3793,8 +3792,6 @@
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
@@ -3927,8 +3924,6 @@
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"poe-oauth": ["poe-oauth@0.0.3", "", {}, "sha512-KgxDylcuq/mov8URSplrBGjrIjkQwjN/Ml8BhqaGsAvHzYN3yhuROdv1sDRfwqncg7TT8XzJvMeJAWmv/4NDLw=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
@@ -5549,8 +5544,6 @@
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -6303,8 +6296,6 @@
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode-poe-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1773909469,
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
"lastModified": 1772091128,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
"rev": "3f0336406035444b4a24b942788334af5f906259",
"type": "github"
},
"original": {

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
"x86_64-linux": "sha256-E5neEbBiwQDhIQ5QVhijpHCCP9hcxm319S9WrDKngSw=",
"aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
"aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
"x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.2",
"version": "1.3.0",
"description": "",
"type": "module",
"exports": {

View File

@@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
<Tooltip value={props.tip} placement="top">
<div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
@@ -363,7 +363,11 @@ export function DebugBar() {
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
"border-color": "color-mix(in srgb, white 14%, transparent)",
}}
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell

View File

@@ -1497,7 +1497,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
@@ -1529,7 +1529,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>

View File

@@ -15,8 +15,6 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
@@ -213,7 +211,6 @@ export function applyDirectoryEvent(input: {
}
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
const parts = input.store.part[part.messageID]
if (!parts) {
input.setStore("part", part.messageID, [part])

View File

@@ -14,8 +14,6 @@ import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
@@ -338,8 +336,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
input.setStore("part", p.id, p.part)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)

View File

@@ -1798,9 +1798,6 @@ export default function Layout(props: ParentProps) {
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
})
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
const panel = createMemo(() => Math.max(side() - 64, 0))
const loadedSessionDirs = new Set<string>()
createEffect(
@@ -2077,7 +2074,7 @@ export default function Layout(props: ParentProps) {
"max-w-full overflow-hidden": panelProps.mobile,
}}
style={{
width: panelProps.mobile ? undefined : `${panel()}px`,
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}}
>
<Show
@@ -2140,11 +2137,9 @@ export default function Layout(props: ParentProps) {
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
}}
aria-label={language.t("common.moreOptions")}
/>
@@ -2369,7 +2364,7 @@ export default function Layout(props: ParentProps) {
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
ref={(el) => {
setState("nav", el)
}}
@@ -2384,29 +2379,24 @@ export default function Layout(props: ParentProps) {
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
<Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setState("sizing", true)}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
@@ -2446,7 +2436,7 @@ export default function Layout(props: ParentProps) {
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
}}
>
<main
@@ -2493,7 +2483,7 @@ export default function Layout(props: ParentProps) {
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>

View File

@@ -104,7 +104,7 @@ const SessionRow = (props: {
}): JSX.Element => (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
@@ -115,26 +115,30 @@ const SessionRow = (props: {
props.clearHoverProjectSoon()
}}
>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</A>
)
@@ -163,11 +167,7 @@ const SessionHoverPreview = (props: {
placement="right-start"
gutter={16}
shift={-2}
trigger={
<div ref={ref} class="min-w-0 w-full">
{props.trigger}
</div>
}
trigger={<div ref={ref}>{props.trigger}</div>}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
@@ -309,71 +309,62 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<div class="flex min-w-0 items-center gap-1">
<div class="min-w-0 flex-1">
<Show
when={hoverEnabled()}
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={props.session.title}
gutter={10}
class="min-w-0 w-full"
>
{item}
</Tooltip>
}
>
<SessionHoverPreview
mobile={props.mobile}
nav={props.nav}
hoverSession={props.hoverSession}
session={props.session}
sidebarHovering={props.sidebarHovering}
hoverReady={hoverReady}
hoverMessages={hoverMessages}
language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
}}
trigger={item}
/>
</Show>
</div>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
<Show
when={hoverEnabled()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
{item}
</Tooltip>
</div>
}
>
<SessionHoverPreview
mobile={props.mobile}
nav={props.nav}
hoverSession={props.hoverSession}
session={props.session}
sidebarHovering={props.sidebarHovering}
hoverReady={hoverReady}
hoverMessages={hoverMessages}
language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
}}
trigger={item}
/>
</Show>
<div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{
"opacity-100 pointer-events-auto": !!props.mobile,
"opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
</div>
</div>
)
@@ -395,26 +386,30 @@ export const NewSessionItem = (props: {
<A
href={`/${props.slug}/session`}
end
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => {
props.setHoverSession(undefined)
if (layout.sidebar.opened()) return
props.clearHoverProjectSoon()
}}
>
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="new-session" size="small" class="text-icon-weak" />
<div class="flex items-center gap-1 w-full">
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="new-session" size="small" class="text-icon-weak" />
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{label}
</span>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
</A>
)
return (
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
<div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
<Show
when={!tooltip()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
{item}
</Tooltip>
}

View File

@@ -41,13 +41,7 @@ import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import {
createOpenReviewFile,
createSessionTabs,
createSizing,
focusTerminalById,
shouldFocusTerminalOnKeyDown,
} from "@/pages/session/helpers"
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
@@ -246,19 +240,14 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (added <= 0) return
if (growth <= 0) return
if (opts?.prefetch) {
const current = turnStart()
preserveScroll(() => setTurnStart(current + growth))
return
}
if (turnStart() !== start) return
const reveal = !opts?.prefetch
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = Math.min(afterVisible, base + turnBatch)
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
const nextStart = Math.max(0, afterVisible - target)
preserveScroll(() => setTurnStart(nextStart))
}
const onScrollerScroll = () => {
@@ -861,7 +850,7 @@ export default function Page() {
// Prefer the open terminal over the composer when it can take focus
if (view().terminal.opened()) {
const id = terminal.active()
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
if (id && focusTerminalById(id)) return
}
// Only treat explicit scroll keys as potential "user scroll" gestures.

View File

@@ -7,7 +7,6 @@ import {
createSessionTabs,
focusTerminalById,
getTabReorderIndex,
shouldFocusTerminalOnKeyDown,
} from "./helpers"
describe("createOpenReviewFile", () => {
@@ -87,26 +86,6 @@ describe("focusTerminalById", () => {
})
})
describe("shouldFocusTerminalOnKeyDown", () => {
test("skips pure modifier keys", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false)
})
test("skips shortcut key combos", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false)
})
test("keeps plain typing focused on terminal", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true)
})
})
describe("getTabReorderIndex", () => {
test("returns target index for valid drag reorder", () => {
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)

View File

@@ -93,13 +93,6 @@ export const focusTerminalById = (id: string) => {
return true
}
const skip = new Set(["Alt", "Control", "Meta", "Shift"])
export const shouldFocusTerminalOnKeyDown = (event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">) => {
if (skip.has(event.key)) return false
return !(event.ctrlKey || event.metaKey || event.altKey)
}
export const createOpenReviewFile = (input: {
showAllFiles: () => void
tabForPath: (path: string) => string

View File

@@ -923,15 +923,7 @@ export function MessageTimeline(props: {
{(messageID) => {
const active = createMemo(() => activeMessageID() === messageID)
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) =>
a.length === b.length &&
a.every(
(c, i) =>
c.path === b[i].path &&
c.comment === b[i].comment &&
c.selection?.startLine === b[i].selection?.startLine &&
c.selection?.endLine === b[i].selection?.endLine,
),
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
})
const commentCount = createMemo(() => comments().length)
return (
@@ -987,7 +979,6 @@ export function MessageTimeline(props: {
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={messageID}
messages={sessionMessages()}
actions={props.actions}
active={active()}
status={active() ? sessionStatus() : undefined}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.2",
"version": "1.3.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -2,6 +2,6 @@ import { redirect } from "@solidjs/router"
export async function GET() {
return redirect(
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true",
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
)
}

View File

@@ -340,13 +340,6 @@ export async function handler(
"error.message": error.message,
"error.cause": error.cause?.toString(),
})
if (error.message.startsWith("Failed query")) {
try {
logger.metric({
"error.cause2": JSON.stringify(error.cause),
})
} catch (e) {}
}
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
if (
@@ -468,17 +461,12 @@ export async function handler(
...modelProvider,
...zenData.providers[modelProvider.id],
...(() => {
const providerProps = zenData.providers[modelProvider.id]
const format = providerProps.format
const format = zenData.providers[modelProvider.id].format
const providerModel = modelProvider.model
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
if (format === "google") return googleHelper({ reqModel, providerModel })
if (format === "openai") return openaiHelper({ reqModel, providerModel })
return oaCompatHelper({
reqModel,
providerModel,
adjustCacheUsage: providerProps.adjustCacheUsage,
})
return oaCompatHelper({ reqModel, providerModel })
})(),
}
}

View File

@@ -21,7 +21,7 @@ type Usage = {
}
}
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
export const oaCompatHelper: ProviderHelper = () => ({
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
@@ -57,15 +57,10 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
}
},
normalizeUsage: (usage: Usage) => {
let inputTokens = usage.prompt_tokens ?? 0
const inputTokens = usage.prompt_tokens ?? 0
const outputTokens = usage.completion_tokens ?? 0
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
if (adjustCacheUsage && !cacheReadTokens) {
cacheReadTokens = Math.floor(inputTokens * 0.9)
}
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens,

View File

@@ -33,7 +33,7 @@ export type UsageInfo = {
cacheWrite1hTokens?: number
}
export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
export type ProviderHelper = (input: { reqModel: string; providerModel: string }) => {
format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.2",
"version": "1.3.0",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,14 +1,7 @@
import { Database, and, eq, sql } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import {
BillingTable,
PaymentTable,
SubscriptionTable,
BlackPlans,
UsageTable,
LiteTable,
} from "../src/schema/billing.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { KeyTable } from "../src/schema/key.sql.js"
import { BlackData } from "../src/black.js"
@@ -79,13 +72,11 @@ else {
workspaceID: UserTable.workspaceID,
workspaceName: WorkspaceTable.name,
role: UserTable.role,
black: SubscriptionTable.timeCreated,
lite: LiteTable.timeCreated,
subscribed: SubscriptionTable.timeCreated,
})
.from(UserTable)
.rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.leftJoin(LiteTable, eq(LiteTable.userID, UserTable.id))
.where(eq(UserTable.accountID, accountID))
.then((rows) =>
rows.map((row) => ({
@@ -93,8 +84,7 @@ else {
workspaceID: row.workspaceID,
workspaceName: row.workspaceName,
role: row.role,
black: formatDate(row.black),
lite: formatDate(row.lite),
subscribed: formatDate(row.subscribed),
})),
),
)
@@ -161,14 +151,13 @@ async function printWorkspace(workspaceID: string) {
balance: BillingTable.balance,
customerID: BillingTable.customerID,
reload: BillingTable.reload,
blackSubscriptionID: BillingTable.subscriptionID,
blackSubscription: {
subscriptionID: BillingTable.subscriptionID,
subscription: {
plan: BillingTable.subscriptionPlan,
booked: BillingTable.timeSubscriptionBooked,
enrichment: BillingTable.subscription,
},
timeBlackSubscriptionSelected: BillingTable.timeSubscriptionSelected,
liteSubscriptionID: BillingTable.liteSubscriptionID,
timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id))
@@ -178,21 +167,16 @@ async function printWorkspace(workspaceID: string) {
balance: `$${(row.balance / 100000000).toFixed(2)}`,
reload: row.reload ? "yes" : "no",
customerID: row.customerID,
liteSubscriptionID: row.liteSubscriptionID,
blackSubscriptionID: row.blackSubscriptionID,
blackSubscription: row.blackSubscriptionID
subscriptionID: row.subscriptionID,
subscription: row.subscriptionID
? [
`Black ${row.blackSubscription.enrichment!.plan}`,
row.blackSubscription.enrichment!.seats > 1
? `X ${row.blackSubscription.enrichment!.seats} seats`
: "",
row.blackSubscription.enrichment!.coupon
? `(coupon: ${row.blackSubscription.enrichment!.coupon})`
: "",
`(ref: ${row.blackSubscriptionID})`,
`Black ${row.subscription.enrichment!.plan}`,
row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "",
row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "",
`(ref: ${row.subscriptionID})`,
].join(" ")
: row.blackSubscription.booked
? `Waitlist ${row.blackSubscription.plan} plan${row.timeBlackSubscriptionSelected ? " (selected)" : ""}`
: row.subscription.booked
? `Waitlist ${row.subscription.plan} plan${row.timeSubscriptionSelected ? " (selected)" : ""}`
: undefined,
}))[0],
),

View File

@@ -48,7 +48,6 @@ export namespace ZenData {
headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(),
adjustCacheUsage: z.boolean().optional(),
})
const ModelsSchema = z.object({

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.2",
"version": "1.3.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.2",
"version": "1.3.0",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.2",
"version": "1.3.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.2",
"version": "1.3.0",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.2"
version = "1.3.0"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.3.2",
"version": "1.3.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.2",
"version": "1.3.0",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -121,7 +121,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.2",
"gitlab-ai-provider": "5.2.2",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -135,7 +135,6 @@
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",

View File

@@ -101,14 +101,6 @@ export default {
],
},
},
{
filetype: "kotlin",
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
queries: {
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
},
},
{
filetype: "ruby",
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
@@ -166,15 +158,6 @@ export default {
// },
// },
},
{
filetype: "hcl",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
],
},
},
{
filetype: "json",
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
@@ -220,16 +203,6 @@ export default {
],
},
},
{
filetype: "lua",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
],
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
},
},
{
filetype: "ocaml",
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
@@ -263,15 +236,6 @@ export default {
],
},
},
{
filetype: "toml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
],
},
},
{
filetype: "nix",
// TODO: Replace with official tree-sitter-nix WASM when published

View File

@@ -21,14 +21,10 @@ const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
path.join(dir, "src/provider/models-snapshot.ts"),
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
)
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
console.log("Generated models-snapshot.ts")
// Load migrations from migration directories
const migrationDirs = (

View File

@@ -173,6 +173,6 @@ Still open and likely worth migrating:
- [ ] `SessionPrompt`
- [ ] `SessionCompaction`
- [ ] `Provider`
- [x] `Project`
- [ ] `Project`
- [ ] `LSP`
- [ ] `MCP`

View File

@@ -3,6 +3,7 @@ import z from "zod"
import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate"
import { Auth } from "../auth"
@@ -19,9 +20,6 @@ import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, ServiceMap, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
export namespace Agent {
export const Info = z
@@ -51,364 +49,295 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Agent.Info>
readonly list: () => Effect.Effect<Agent.Info[]>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}
const state = Instance.state(async () => {
const cfg = await Config.get()
type State = Omit<Interface, "generate">
const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
const user = Permission.fromConfig(cfg.permission ?? {})
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = () => Effect.promise(() => Config.get())
const auth = yield* Auth.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config()
const skillDirs = yield* Effect.promise(() => Skill.dirs())
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
const result: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
const user = Permission.fromConfig(cfg.permission ?? {})
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
continue
}
let item = result[key]
if (!item)
item = result[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
const agents: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
"allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete agents[key]
continue
}
let item = agents[key]
if (!item)
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
const get = Effect.fnUntraced(function* (agent: string) {
return agents[agent]
})
const list = Effect.fnUntraced(function* () {
const cfg = yield* config()
return pipe(
agents,
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = Effect.fnUntraced(function* () {
const c = yield* config()
if (c.default_agent) {
const agent = agents[c.default_agent]
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent.name
}
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible.name
})
return {
get,
list,
defaultAgent,
} satisfies State
}),
)
return Service.of({
get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config()
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
const system = [PROMPT_GENERATE]
yield* Effect.promise(() =>
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
)
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
),
{
role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
if (model.providerID === "openai" && authInfo?.type === "oauth") {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
})
}
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
}),
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in result) {
const agent = result[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
}),
)
if (explicit) continue
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
result[name].permission = Permission.merge(
result[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
const runPromise = makeRunPromise(Service, defaultLayer)
return result
})
export async function get(agent: string) {
return runPromise((svc) => svc.get(agent))
return state().then((x) => x[agent])
}
export async function list() {
return runPromise((svc) => svc.list())
const cfg = await Config.get()
return pipe(
await state(),
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
}
export async function defaultAgent() {
return runPromise((svc) => svc.defaultAgent())
const cfg = await Config.get()
const agents = await state()
if (cfg.default_agent) {
const agent = agents[cfg.default_agent]
if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`)
return agent.name
}
const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!primaryVisible) throw new Error("no primary visible agent found")
return primaryVisible.name
}
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
return runPromise((svc) => svc.generate(input))
const cfg = await Config.get()
const defaultModel = input.model ?? (await Provider.defaultModel())
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = [PROMPT_GENERATE]
await Plugin.trigger("experimental.chat.system.transform", { model }, { system })
const existing = await list()
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
),
{
role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(model, {
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
}
const result = await generateObject(params)
return result.object
}
}

View File

@@ -10,26 +10,6 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const dim = (value: string) => UI.Style.TEXT_DIM + value + UI.Style.TEXT_NORMAL
const activeSuffix = (isActive: boolean) => (isActive ? dim(" (active)") : "")
export const formatAccountLabel = (account: { email: string; url: string }, isActive: boolean) =>
`${account.email} ${dim(account.url)}${activeSuffix(isActive)}`
const formatOrgChoiceLabel = (account: { email: string }, org: { name: string }, isActive: boolean) =>
`${org.name} (${account.email})${activeSuffix(isActive)}`
export const formatOrgLine = (
account: { email: string; url: string },
org: { id: string; name: string },
isActive: boolean,
) => {
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
return ` ${dot} ${name} ${dim(account.email)} ${dim(account.url)} ${dim(org.id)}`
}
const isActiveOrgChoice = (
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
choice: { accountID: AccountID; orgID: OrgID },
@@ -96,9 +76,10 @@ const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const opts = accounts.map((a) => {
const isActive = Option.isSome(activeID) && activeID.value === a.id
const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
return {
value: a,
label: formatAccountLabel(a, isActive),
label: isActive ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)" : `${a.email} ${server}`,
}
})
@@ -128,7 +109,9 @@ const switchEffect = Effect.fn("switch")(function* () {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: formatOrgChoiceLabel(group.account, org, isActive),
label: isActive
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
: `${org.name} (${group.account.email})`,
}
}),
)
@@ -156,21 +139,15 @@ const orgsEffect = Effect.fn("orgs")(function* () {
for (const group of groups) {
for (const org of group.orgs) {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
yield* println(formatOrgLine(group.account, org, isActive))
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
yield* println(` ${dot} ${name} ${email} ${id}`)
}
}
})
const openEffect = Effect.fn("open")(function* () {
const service = yield* Account.Service
const active = yield* service.active()
if (Option.isNone(active)) return yield* println("No active account")
const url = active.value.url
yield* openBrowser(url)
yield* Prompt.outro("Opened " + url)
})
export const LoginCommand = cmd({
command: "login <url>",
describe: false,
@@ -218,15 +195,6 @@ export const OrgsCommand = cmd({
},
})
export const OpenCommand = cmd({
command: "open",
describe: false,
async handler() {
UI.empty()
await Account.runPromise((_svc) => openEffect())
},
})
export const ConsoleCommand = cmd({
command: "console",
describe: false,
@@ -248,10 +216,6 @@ export const ConsoleCommand = cmd({
...OrgsCommand,
describe: "list orgs",
})
.command({
...OpenCommand,
describe: "open active console account",
})
.demandCommand(),
async handler() {},
})

View File

@@ -110,7 +110,6 @@ export function tui(input: {
url: string
args: Args
config: TuiConfig.Info
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
@@ -161,7 +160,7 @@ export function tui(input: {
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
@@ -202,7 +201,7 @@ export function tui(input: {
})
}
function App(props: { onSnapshot?: () => Promise<string[]> }) {
function App() {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@@ -213,7 +212,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const command = useCommandDialog()
const sdk = useSDK()
const toast = useToast()
const { theme, mode, setMode, locked, lock, unlock } = useTheme()
const { theme, mode, setMode } = useTheme()
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
@@ -558,7 +557,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System",
},
{
title: "Toggle Theme Mode",
title: "Toggle appearance",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
@@ -566,16 +565,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
category: "System",
},
{
title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode",
value: "theme.mode.lock",
onSelect: (dialog) => {
if (locked()) unlock()
else lock()
dialog.clear()
},
category: "System",
},
{
title: "Help",
value: "help.show",
@@ -628,11 +617,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
title: "Write heap snapshot",
category: "System",
value: "app.heap_snapshot",
onSelect: async (dialog) => {
const files = await props.onSnapshot?.()
onSelect: (dialog) => {
const path = writeHeapSnapshot()
toast.show({
variant: "info",
message: `Heap snapshot written to ${files?.join(", ")}`,
message: `Heap snapshot written to ${path}`,
duration: 5000,
})
dialog.clear()

View File

@@ -53,7 +53,6 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
message: store,
},
)
process.on("SIGHUP", () => exit())
return exit
},
})

View File

@@ -1,6 +1,6 @@
import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createEffect, createMemo, onMount } from "solid-js"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
@@ -280,18 +280,11 @@ function ansiToRgba(code: number): RGBA {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
const renderer = useRenderer()
const config = useTuiConfig()
const kv = useKV()
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
}
const lock = pick(kv.get("theme_mode_lock"))
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
lock,
mode: kv.get("theme_mode", props.mode),
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
@@ -302,7 +295,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
function init() {
resolveSystemTheme(store.mode)
resolveSystemTheme()
getCustomThemes()
.then((custom) => {
setStore(
@@ -323,12 +316,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
onMount(init)
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
function resolveSystemTheme() {
console.log("resolveSystemTheme")
renderer
.getPalette({
size: 16,
})
.then((colors) => {
console.log(colors.palette)
if (!colors.palette[0]) {
if (store.active === "system") {
setStore(
@@ -342,7 +337,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, mode)
draft.themes.system = generateSystem(colors, store.mode)
if (store.active === "system") {
draft.ready = true
}
@@ -351,44 +346,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
}
function apply(mode: "dark" | "light") {
kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
const renderer = useRenderer()
process.on("SIGUSR2", async () => {
renderer.clearPaletteCache()
resolveSystemTheme(mode)
}
function pin(mode: "dark" | "light" = store.mode) {
setStore("lock", mode)
kv.set("theme_mode_lock", mode)
apply(mode)
}
function free() {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
const mode = renderer.themeMode
if (mode) apply(mode)
}
const handle = (mode: "dark" | "light") => {
if (store.lock) return
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)
init()
})
const values = createMemo(() => {
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
})
createEffect(() => {
renderer.setBackgroundColor(values().background)
})
const syntax = createMemo(() => generateSyntax(values()))
const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
@@ -410,17 +377,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
mode() {
return store.mode
},
locked() {
return store.lock !== undefined
},
lock() {
pin(store.mode)
},
unlock() {
free()
},
setMode(mode: "dark" | "light") {
pin(mode)
setStore("mode", mode)
kv.set("theme_mode", mode)
},
set(theme: string) {
setStore("active", theme)

View File

@@ -14,7 +14,6 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { writeHeapSnapshot } from "v8"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -202,11 +201,6 @@ export const TuiThreadCommand = cmd({
try {
await tui({
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
directory: cwd,
fetch: transport.fetch,

View File

@@ -10,7 +10,6 @@ import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -118,10 +117,6 @@ export const rpc = {
body,
}
},
snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot")
return result
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = await Server.listen(input)

View File

@@ -1,476 +0,0 @@
import type * as Arr from "effect/Array"
import { NodeSink, NodeStream } from "@effect/platform-node"
import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Path from "effect/Path"
import * as PlatformError from "effect/PlatformError"
import * as Predicate from "effect/Predicate"
import type * as Scope from "effect/Scope"
import * as Sink from "effect/Sink"
import * as Stream from "effect/Stream"
import * as ChildProcess from "effect/unstable/process/ChildProcess"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import {
ChildProcessSpawner,
ExitCode,
make as makeSpawner,
makeHandle,
ProcessId,
} from "effect/unstable/process/ChildProcessSpawner"
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
switch (err.code) {
case "ENOENT":
return "NotFound"
case "EACCES":
return "PermissionDenied"
case "EEXIST":
return "AlreadyExists"
case "EISDIR":
return "BadResource"
case "ENOTDIR":
return "BadResource"
case "EBUSY":
return "Busy"
case "ELOOP":
return "BadResource"
default:
return "Unknown"
}
}
const flatten = (command: ChildProcess.Command) => {
const commands: Array<ChildProcess.StandardCommand> = []
const opts: Array<ChildProcess.PipeOptions> = []
const walk = (cmd: ChildProcess.Command): void => {
switch (cmd._tag) {
case "StandardCommand":
commands.push(cmd)
return
case "PipedCommand":
walk(cmd.left)
opts.push(cmd.options)
walk(cmd.right)
return
}
}
walk(command)
if (commands.length === 0) throw new Error("flatten produced empty commands array")
const [head, ...tail] = commands
return {
commands: [head, ...tail] as Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
opts,
}
}
const toPlatformError = (
method: string,
err: NodeJS.ErrnoException,
command: ChildProcess.Command,
): PlatformError.PlatformError => {
const cmd = flatten(command)
.commands.map((x) => `${x.command} ${x.args.join(" ")}`)
.join(" | ")
return PlatformError.systemError({
_tag: toTag(err),
module: "ChildProcess",
method,
pathOrDescriptor: cmd,
syscall: err.syscall,
cause: err,
})
}
type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]>
export const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) {
if (Predicate.isUndefined(opts.cwd)) return undefined
yield* fs.access(opts.cwd)
return path.resolve(opts.cwd)
})
const env = (opts: ChildProcess.CommandOptions) =>
opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env
const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined =>
Stream.isStream(x) ? "pipe" : x
const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined =>
Sink.isSink(x) ? "pipe" : x
const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => {
const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true }
if (Predicate.isUndefined(opts.stdin)) return cfg
if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin }
if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin }
return {
stream: opts.stdin.stream,
encoding: opts.stdin.encoding ?? cfg.encoding,
endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone,
}
}
const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => {
const cfg = opts[key]
if (Predicate.isUndefined(cfg)) return { stream: "pipe" }
if (typeof cfg === "string") return { stream: cfg }
if (Sink.isSink(cfg)) return { stream: cfg }
return { stream: cfg.stream }
}
const fds = (opts: ChildProcess.CommandOptions) => {
if (Predicate.isUndefined(opts.additionalFds)) return []
return Object.entries(opts.additionalFds)
.flatMap(([name, config]) => {
const fd = ChildProcess.parseFdName(name)
return Predicate.isUndefined(fd) ? [] : [{ fd, config }]
})
.toSorted((a, b) => a.fd - b.fd)
}
const stdios = (
sin: ChildProcess.StdinConfig,
sout: ChildProcess.StdoutConfig,
serr: ChildProcess.StderrConfig,
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
): NodeChildProcess.StdioOptions => {
const pipe = (x: NodeChildProcess.IOType | undefined) =>
process.platform === "win32" && x === "pipe" ? "overlapped" : x
const arr: Array<NodeChildProcess.IOType | undefined> = [
pipe(input(sin.stream)),
pipe(output(sout.stream)),
pipe(output(serr.stream)),
]
if (extra.length === 0) return arr as NodeChildProcess.StdioOptions
const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2)
for (let i = 3; i <= max; i++) arr[i] = "ignore"
for (const x of extra) arr[x.fd] = pipe("pipe")
return arr as NodeChildProcess.StdioOptions
}
const setupFds = Effect.fnUntraced(function* (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
) {
if (extra.length === 0) {
return {
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}
}
const ins = new Map<number, Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError>>()
const outs = new Map<number, Stream.Stream<Uint8Array, PlatformError.PlatformError>>()
for (const x of extra) {
const node = proc.stdio[x.fd]
switch (x.config.type) {
case "input": {
let sink: Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError> = Sink.drain
if (node && "write" in node) {
sink = NodeSink.fromWritable({
evaluate: () => node,
onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command),
endOnDone: true,
})
}
if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink))
ins.set(x.fd, sink)
break
}
case "output": {
let stream: Stream.Stream<Uint8Array, PlatformError.PlatformError> = Stream.empty
if (node && "read" in node) {
const tap = new PassThrough()
node.on("error", (err) => tap.destroy(toError(err)))
node.pipe(tap)
stream = NodeStream.fromReadable({
evaluate: () => tap,
onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command),
})
}
if (x.config.sink) stream = Stream.transduce(stream, x.config.sink)
outs.set(x.fd, stream)
break
}
}
}
return {
getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain,
getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty,
}
})
const setupStdin = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
cfg: ChildProcess.StdinConfig,
) =>
Effect.suspend(() => {
let sink: Sink.Sink<void, unknown, never, PlatformError.PlatformError> = Sink.drain
if (Predicate.isNotNull(proc.stdin)) {
sink = NodeSink.fromWritable({
evaluate: () => proc.stdin!,
onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command),
endOnDone: cfg.endOnDone,
encoding: cfg.encoding,
})
}
if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink)
return Effect.succeed(sink)
})
const setupOutput = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
out: ChildProcess.StdoutConfig,
err: ChildProcess.StderrConfig,
) => {
let stdout = proc.stdout
? NodeStream.fromReadable({
evaluate: () => proc.stdout!,
onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command),
})
: Stream.empty
let stderr = proc.stderr
? NodeStream.fromReadable({
evaluate: () => proc.stderr!,
onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command),
})
: Stream.empty
if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream)
if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream)
return { stdout, stderr, all: Stream.merge(stdout, stderr) }
}
const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args, opts)
let end = false
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
proc.on("error", (err) => {
resume(Effect.fail(toPlatformError("spawn", err, command)))
})
proc.on("exit", (...args) => {
exit = args
})
proc.on("close", (...args) => {
if (end) return
end = true
Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args))
})
proc.on("spawn", () => {
resume(Effect.succeed([proc, signal]))
})
return Effect.sync(() => {
proc.kill("SIGTERM")
})
})
const killGroup = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals,
) => {
if (globalThis.process.platform === "win32") {
return Effect.callback<void, PlatformError.PlatformError>((resume) => {
NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => {
if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command)))
resume(Effect.void)
})
})
}
return Effect.try({
try: () => {
globalThis.process.kill(-proc.pid!, signal)
},
catch: (err) => toPlatformError("kill", toError(err), command),
})
}
const killOne = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals,
) =>
Effect.suspend(() => {
if (proc.kill(signal)) return Effect.void
return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command))
})
const timeout =
(
proc: NodeChildProcess.ChildProcess,
command: ChildProcess.StandardCommand,
opts: ChildProcess.KillOptions | undefined,
) =>
<A, E, R>(
f: (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals,
) => Effect.Effect<A, E, R>,
) => {
const signal = opts?.killSignal ?? "SIGTERM"
if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal)
return Effect.timeoutOrElse(f(command, proc, signal), {
duration: opts.forceKillAfter,
onTimeout: () => f(command, proc, "SIGKILL"),
})
}
const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => {
const opt = from ?? "stdout"
switch (opt) {
case "stdout":
return handle.stdout
case "stderr":
return handle.stderr
case "all":
return handle.all
default: {
const fd = ChildProcess.parseFdName(opt)
return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout
}
}
}
const spawnCommand: (
command: ChildProcess.Command,
) => Effect.Effect<ChildProcessHandle, PlatformError.PlatformError, Scope.Scope> = Effect.fnUntraced(
function* (command) {
switch (command._tag) {
case "StandardCommand": {
const sin = stdin(command.options)
const sout = stdio(command.options, "stdout")
const serr = stdio(command.options, "stderr")
const extra = fds(command.options)
const dir = yield* cwd(command.options)
const [proc, signal] = yield* Effect.acquireRelease(
spawn(command, {
cwd: dir,
env: env(command.options),
stdio: stdios(sin, sout, serr, extra),
detached: command.options.detached ?? process.platform !== "win32",
shell: command.options.shell,
windowsHide: process.platform === "win32",
}),
Effect.fnUntraced(function* ([proc, signal]) {
const done = yield* Deferred.isDone(signal)
const kill = timeout(proc, command, command.options)
if (done) {
const [code] = yield* Deferred.await(signal)
if (process.platform === "win32") return yield* Effect.void
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
return yield* Effect.void
}
return yield* kill((command, proc, signal) =>
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
}),
)
const fd = yield* setupFds(command, proc, extra)
const out = setupOutput(command, proc, sout, serr)
return makeHandle({
pid: ProcessId(proc.pid!),
stdin: yield* setupStdin(command, proc, sin),
stdout: out.stdout,
stderr: out.stderr,
all: out.all,
getInputFd: fd.getInputFd,
getOutputFd: fd.getOutputFd,
isRunning: Effect.map(Deferred.isDone(signal), (done) => !done),
exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => {
if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))
return Effect.fail(
toPlatformError(
"exitCode",
new Error(`Process interrupted due to receipt of signal: '${signal}'`),
command,
),
)
}),
kill: (opts?: ChildProcess.KillOptions) =>
timeout(
proc,
command,
opts,
)((command, proc, signal) =>
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
})
}
case "PipedCommand": {
const flat = flatten(command)
const [head, ...tail] = flat.commands
let handle = spawnCommand(head)
for (let i = 0; i < tail.length; i++) {
const next = tail[i]
const opts = flat.opts[i] ?? {}
const sin = stdin(next.options)
const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from)))
const to = opts.to ?? "stdin"
if (to === "stdin") {
handle = spawnCommand(
ChildProcess.make(next.command, next.args, {
...next.options,
stdin: { ...sin, stream },
}),
)
continue
}
const fd = ChildProcess.parseFdName(to)
if (Predicate.isUndefined(fd)) {
handle = spawnCommand(
ChildProcess.make(next.command, next.args, {
...next.options,
stdin: { ...sin, stream },
}),
)
continue
}
handle = spawnCommand(
ChildProcess.make(next.command, next.args, {
...next.options,
additionalFds: {
...next.options.additionalFds,
[ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream },
},
}),
)
}
return yield* handle
}
}
},
)
return makeSpawner(spawnCommand)
})
export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
ChildProcessSpawner,
make,
)

View File

@@ -1,7 +1,6 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@@ -341,7 +340,7 @@ export namespace Installation {
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)

View File

@@ -11,7 +11,6 @@ import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
@@ -45,7 +44,7 @@ export namespace Plugin {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
// Old npm package names for plugins that are now built-in — skip if users still have them in config
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
@@ -137,11 +136,7 @@ export namespace Plugin {
// Notify plugins of current config
for (const hook of hooks) {
try {
await (hook as any).config?.(cfg)
} catch (err) {
log.error("plugin config hook failed", { error: err })
}
await (hook as any).config?.(cfg)
}
})

View File

@@ -1,23 +1,36 @@
import z from "zod"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { and, Database, eq } from "../storage/db"
import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which"
import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
export namespace Project {
const log = Log.create({ service: "project" })
function gitpath(cwd: string, name: string) {
if (!name) return cwd
// git output includes trailing newlines; keep path whitespace intact.
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = Filesystem.windowsPath(name)
if (path.isAbsolute(name)) return path.normalize(name)
return path.resolve(cwd, name)
}
export const Info = z
.object({
id: ProjectID.zod,
@@ -60,7 +73,7 @@ export namespace Project {
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
: undefined
return {
id: row.id,
id: ProjectID.make(row.id),
worktree: row.worktree,
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined,
@@ -75,405 +88,245 @@ export namespace Project {
}
}
export const UpdateInput = z.object({
projectID: ProjectID.zod,
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
export interface Interface {
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
function readCachedId(dir: string) {
return Filesystem.readText(path.join(dir, "opencode"))
.then((x) => x.trim())
.then(ProjectID.make)
.catch(() => undefined)
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory })
type GitResult = { code: number; text: string; stderr: string }
const data = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
if (dotgit) {
let sandbox = path.dirname(dotgit)
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const gitBinary = which("git")
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
)
// cached id calculation
let id = await readCachedId(dotgit)
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
if (!gitBinary) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
const emitUpdated = (data: Info) =>
Effect.sync(() =>
GlobalBus.emit("event", {
payload: { type: Event.Updated.type, properties: data },
}),
)
const worktree = await git(["rev-parse", "--git-common-dir"], {
cwd: sandbox,
})
.then(async (result) => {
const common = gitpath(sandbox, await result.text())
// Avoid going to parent of sandbox when git-common-dir is empty.
return common === sandbox ? sandbox : path.dirname(common)
})
.catch(() => undefined)
const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
if (!worktree) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
const resolveGitPath = (cwd: string, name: string) => {
if (!name) return cwd
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = AppFileSystem.windowsPath(name)
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
// In the case of a git worktree, it can't cache the id
// because `.git` is not a folder, but it always needs the
// same project id as the common dir, so we resolve it now
if (id == null) {
id = await readCachedId(path.join(worktree, ".git"))
}
const scope = yield* Scope.Scope
// generate id from root commit
if (!id) {
const roots = await git(["rev-list", "--max-parents=0", "HEAD"], {
cwd: sandbox,
})
.then(async (result) =>
(await result.text())
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.catch(() => undefined)
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
Effect.catch(() => Effect.succeed(undefined)),
)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
// Phase 1: discover git info
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
const data: DiscoveryResult = yield* Effect.gen(function* () {
const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
const dotgit = dotgitMatches[0]
if (!dotgit) {
if (!roots) {
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: fakeVcs,
}
}
let sandbox = pathSvc.dirname(dotgit)
const gitBinary = yield* Effect.sync(() => which("git"))
let id = yield* readCachedProjectId(dotgit)
if (!gitBinary) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
if (commonDir.code !== 0) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
// Write to common dir so the cache is shared across worktrees.
await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined)
}
const worktree = (() => {
const common = resolveGitPath(sandbox, commonDir.text.trim())
return common === sandbox ? sandbox : pathSvc.dirname(common)
})()
}
if (id == null) {
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
if (!id) {
return {
id: ProjectID.global,
worktree: sandbox,
sandbox,
vcs: "git",
}
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
const roots = revList.text
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
}
}
if (!id) {
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
if (topLevel.code !== 0) {
return {
id,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
return { id, sandbox, worktree, vcs: "git" as const }
const top = await git(["rev-parse", "--show-toplevel"], {
cwd: sandbox,
})
.then(async (result) => gitpath(sandbox, await result.text()))
.catch(() => undefined)
// Phase 2: upsert
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree,
vcs: data.vcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
if (!top) {
return {
id,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
sandbox = top
const result: Info = {
...existing,
return {
id,
sandbox,
worktree,
vcs: "git",
}
}
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
})
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree,
vcs: data.vcs,
time: { ...existing.time, updated: Date.now() },
}
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) =>
fsys.exists(s).pipe(
Effect.orDie,
Effect.map((exists) => (exists ? s : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
yield* db((d) =>
d
.insert(ProjectTable)
.values({
id: result.id,
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
})
.onConflictDoUpdate({
target: ProjectTable.id,
set: {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
},
})
.run(),
)
if (data.id !== ProjectID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.run(),
)
vcs: data.vcs as Info["vcs"],
sandboxes: [] as string[],
time: {
created: Date.now(),
updated: Date.now(),
},
}
yield* emitUpdated(result)
return { project: result, sandbox: data.sandbox }
})
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const discover = Effect.fn("Project.discover")(function* (input: Info) {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const matches = yield* fsys
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
.pipe(Effect.orDie)
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
const base64 = Buffer.from(buffer).toString("base64")
const mime = AppFileSystem.mimeType(shortest)
const url = `data:${mime};base64,${base64}`
yield* update({ projectID: input.id, icon: { url } })
})
const list = Effect.fn("Project.list")(function* () {
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
})
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
return row ? fromRow(row) : undefined
})
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
yield* emitUpdated(data)
return data
})
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
if (input.project.vcs === "git") return input.project
if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
if (result.code !== 0) {
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
}
const { project } = yield* fromDirectory(input.directory)
return project
})
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
yield* db((d) =>
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
return yield* Effect.forEach(
data.sandboxes,
(dir) =>
fsys.isDir(dir).pipe(
Effect.orDie,
Effect.map((ok) => (ok ? dir : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
})
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
return Service.of({
fromDirectory,
discover,
list,
get,
update,
initGit,
setInitialized,
sandboxes,
addSandbox,
removeSandbox,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
// ---------------------------------------------------------------------------
// Promise-based API (delegates to Effect service via runPromise)
// ---------------------------------------------------------------------------
export function fromDirectory(directory: string) {
return runPromise((svc) => svc.fromDirectory(directory))
const result: Info = {
...existing,
worktree: data.worktree,
vcs: data.vcs as Info["vcs"],
time: {
...existing.time,
updated: Date.now(),
},
}
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
const insert = {
id: result.id,
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
}
const updateSet = {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
}
Database.use((db) =>
db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(),
)
// Runs after upsert so the target project row exists (FK constraint).
// Runs on every startup because sessions created before git init
// accumulate under "global" and need migrating whenever they appear.
if (data.id !== ProjectID.global) {
Database.use((db) =>
db
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.run(),
)
}
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
return { project: result, sandbox: data.sandbox }
}
export function discover(input: Info) {
return runPromise((svc) => svc.discover(input))
export async function discover(input: Info) {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const buffer = await Filesystem.readBytes(shortest)
const base64 = buffer.toString("base64")
const mime = Filesystem.mimeType(shortest) || "image/png"
const url = `data:${mime};base64,${base64}`
await update({
projectID: input.id,
icon: {
url,
},
})
return
}
export function setInitialized(id: ProjectID) {
Database.use((db) =>
db
.update(ProjectTable)
.set({
time_initialized: Date.now(),
})
.where(eq(ProjectTable.id, id))
.run(),
)
}
export function list() {
@@ -492,29 +345,112 @@ export namespace Project {
return fromRow(row)
}
export function setInitialized(id: ProjectID) {
Database.use((db) =>
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
export async function initGit(input: { directory: string; project: Info }) {
if (input.project.vcs === "git") return input.project
if (!which("git")) throw new Error("Git is not installed")
const result = await git(["init", "--quiet"], {
cwd: input.directory,
})
if (result.exitCode !== 0) {
const text = result.stderr.toString().trim() || result.text().trim()
throw new Error(text || "Failed to initialize git repository")
}
return (await fromDirectory(input.directory)).project
}
export const update = fn(
z.object({
projectID: ProjectID.zod,
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
}),
async (input) => {
const id = ProjectID.make(input.projectID)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
},
)
export async function sandboxes(id: ProjectID) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
const valid: string[] = []
for (const dir of data.sandboxes) {
const s = Filesystem.stat(dir)
if (s?.isDirectory()) valid.push(dir)
}
return valid
}
export async function addSandbox(id: ProjectID, directory: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sandboxes = [...row.sandboxes]
if (!sandboxes.includes(directory)) sandboxes.push(directory)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
}
export function initGit(input: { directory: string; project: Info }) {
return runPromise((svc) => svc.initGit(input))
}
export function update(input: UpdateInput) {
return runPromise((svc) => svc.update(input))
}
export function sandboxes(id: ProjectID) {
return runPromise((svc) => svc.sandboxes(id))
}
export function addSandbox(id: ProjectID, directory: string) {
return runPromise((svc) => svc.addSandbox(id, directory))
}
export function removeSandbox(id: ProjectID, directory: string) {
return runPromise((svc) => svc.removeSandbox(id, directory))
export async function removeSandbox(id: ProjectID, directory: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sandboxes = row.sandboxes.filter((s) => s !== directory)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
}
}

View File

@@ -89,7 +89,7 @@ export namespace ModelsDev {
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
if (result) return result
// @ts-ignore
const snapshot = await import("./models-snapshot.js")
const snapshot = await import("./models-snapshot")
.then((m) => m.snapshot as Record<string, unknown>)
.catch(() => undefined)
if (snapshot) return snapshot

View File

@@ -194,10 +194,7 @@ export namespace ProviderTransform {
}
for (const msg of unique([...system, ...final])) {
const useMessageLevelOptions =
model.providerID === "anthropic" ||
model.providerID.includes("bedrock") ||
model.api.npm === "@ai-sdk/amazon-bedrock"
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock")
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) {

View File

@@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() =>
},
}),
validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.UpdateInput.omit({ projectID: true })),
validator("json", Project.update.schema.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")

View File

@@ -19,8 +19,6 @@ import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Bus } from "../../bus"
import { NamedError } from "@opencode-ai/util/error"
const log = Log.create({ service: "server" })
@@ -848,13 +846,7 @@ export const SessionRoutes = lazy(() =>
return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
})
SessionPrompt.prompt({ ...body, sessionID })
})
},
)

View File

@@ -113,20 +113,17 @@ export namespace LLM {
options.instructions = system.join("\n")
}
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = await Plugin.trigger(
"chat.params",
@@ -193,7 +190,6 @@ export namespace LLM {
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
@@ -254,16 +250,12 @@ export namespace LLM {
maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"User-Agent": `opencode/${Installation.VERSION}`,
}),
...(input.model.providerID.startsWith("opencode") && {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}),
...input.model.headers,
...headers,
},

View File

@@ -418,16 +418,6 @@ export namespace SessionPrompt {
)
let executionError: Error | undefined
const taskAgent = await Agent.get(task.agent)
if (!taskAgent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID,
error: error.toObject(),
})
throw error
}
const taskCtx: Tool.Context = {
agent: task.agent,
messageID: assistantMessage.id,
@@ -570,16 +560,6 @@ export namespace SessionPrompt {
// normal processing
const agent = await Agent.get(lastUser.agent)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID,
error: error.toObject(),
})
throw error
}
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = await insertReminders({
@@ -984,18 +964,7 @@ export namespace SessionPrompt {
}
async function createUserMessage(input: PromptInput) {
const agentName = input.agent || (await Agent.defaultAgent())
const agent = await Agent.get(agentName)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
}
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const full =
@@ -1562,16 +1531,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
await SessionRevert.cleanup(session)
}
const agent = await Agent.get(input.agent)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
}
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const userMsg: MessageV2.User = {
id: MessageID.ascending(),
@@ -1824,14 +1783,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
log.info("command", input)
const command = await Command.get(input.command)
if (!command) {
const available = await Command.list().then((cmds) => cmds.map((c) => c.name))
const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` })
}
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())

View File

@@ -1,9 +1,8 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
@@ -355,9 +354,9 @@ export namespace Snapshot {
)
export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
Layer.provide(NodePath.layer),
)

View File

@@ -33,6 +33,16 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
return text.replaceAll("\n", "\r\n")
}
function stats(before: string, after: string) {
let additions = 0
let deletions = 0
for (const change of diffLines(before, after)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
return { additions, deletions }
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
@@ -115,23 +125,16 @@ export const EditTool = Tool.define("edit", {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await FileTime.read(ctx.sessionID, filePath)
})
const count = stats(contentOld, contentNew)
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
additions: count.additions,
deletions: count.deletions,
}
ctx.metadata({
@@ -167,7 +170,18 @@ export const EditTool = Tool.define("edit", {
},
})
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
type Prep = {
content: string
find: string
lines: string[]
finds: string[]
}
export type Replacer = (prep: Prep) => Generator<string, void, unknown>
function trimLastEmpty(lines: string[]) {
return lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines
}
// Similarity thresholds for block anchor fallback matching
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
@@ -181,37 +195,36 @@ function levenshtein(a: string, b: string): number {
if (a === "" || b === "") {
return Math.max(a.length, b.length)
}
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
)
const [left, right] = a.length < b.length ? [a, b] : [b, a]
let prev = Array.from({ length: left.length + 1 }, (_, i) => i)
let next = Array.from({ length: left.length + 1 }, () => 0)
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
for (let i = 1; i <= right.length; i++) {
next[0] = i
for (let j = 1; j <= left.length; j++) {
const cost = right[i - 1] === left[j - 1] ? 0 : 1
next[j] = Math.min(next[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
}
}
return matrix[a.length][b.length]
}
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
}
export const LineTrimmedReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
;[prev, next] = [next, prev]
}
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
return prev[left.length]
}
export const SimpleReplacer: Replacer = function* (prep) {
yield prep.find
}
export const LineTrimmedReplacer: Replacer = function* (prep) {
const original = prep.lines
const search = trimLastEmpty(prep.finds)
for (let i = 0; i <= original.length - search.length; i++) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
for (let j = 0; j < search.length; j++) {
const originalTrimmed = original[i + j].trim()
const searchTrimmed = search[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
@@ -222,48 +235,42 @@ export const LineTrimmedReplacer: Replacer = function* (content, find) {
if (matches) {
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
matchStartIndex += original[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length
if (k < searchLines.length - 1) {
for (let k = 0; k < search.length; k++) {
matchEndIndex += original[i + k].length
if (k < search.length - 1) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
yield prep.content.substring(matchStartIndex, matchEndIndex)
}
}
}
export const BlockAnchorReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
export const BlockAnchorReplacer: Replacer = function* (prep) {
const original = prep.lines
const search = trimLastEmpty(prep.finds)
if (searchLines.length < 3) {
return
}
if (search.length < 3) return
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
const firstLineSearch = search[0].trim()
const lastLineSearch = search[search.length - 1].trim()
const searchBlockSize = search.length
// Collect all candidate positions where both anchors match
const candidates: Array<{ startLine: number; endLine: number }> = []
for (let i = 0; i < originalLines.length; i++) {
if (originalLines[i].trim() !== firstLineSearch) {
for (let i = 0; i < original.length; i++) {
if (original[i].trim() !== firstLineSearch) {
continue
}
// Look for the matching last line after this first line
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
for (let j = i + 2; j < original.length; j++) {
if (original[j].trim() === lastLineSearch) {
candidates.push({ startLine: i, endLine: j })
break // Only match the first occurrence of the last line
}
@@ -285,8 +292,8 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const originalLine = original[startLine + j].trim()
const searchLine = search[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
@@ -307,16 +314,16 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
matchStartIndex += original[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
matchEndIndex += original[k].length
if (k < endLine) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
yield prep.content.substring(matchStartIndex, matchEndIndex)
}
return
}
@@ -334,8 +341,8 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const originalLine = original[startLine + j].trim()
const searchLine = search[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
@@ -360,25 +367,25 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
const { startLine, endLine } = bestMatch
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
matchStartIndex += original[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
matchEndIndex += original[k].length
if (k < endLine) {
matchEndIndex += 1
}
}
yield content.substring(matchStartIndex, matchEndIndex)
yield prep.content.substring(matchStartIndex, matchEndIndex)
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
const normalizedFind = normalizeWhitespace(prep.find)
// Handle single line matches
const lines = content.split("\n")
const lines = prep.lines
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (normalizeWhitespace(line) === normalizedFind) {
@@ -388,7 +395,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
const words = prep.find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
@@ -406,7 +413,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
}
// Handle multi-line matches
const findLines = find.split("\n")
const findLines = prep.finds
if (findLines.length > 1) {
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length)
@@ -417,7 +424,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
}
}
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
export const IndentationFlexibleReplacer: Replacer = function* (prep) {
const removeIndentation = (text: string) => {
const lines = text.split("\n")
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
@@ -433,9 +440,9 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
}
const normalizedFind = removeIndentation(find)
const contentLines = content.split("\n")
const findLines = find.split("\n")
const normalizedFind = removeIndentation(prep.find)
const contentLines = prep.lines
const findLines = prep.finds
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
const block = contentLines.slice(i, i + findLines.length).join("\n")
@@ -445,7 +452,7 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
}
}
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
export const EscapeNormalizedReplacer: Replacer = function* (prep) {
const unescapeString = (str: string): string => {
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
switch (capturedChar) {
@@ -473,15 +480,15 @@ export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
})
}
const unescapedFind = unescapeString(find)
const unescapedFind = unescapeString(prep.find)
// Try direct match with unescaped find string
if (content.includes(unescapedFind)) {
if (prep.content.includes(unescapedFind)) {
yield unescapedFind
}
// Also try finding escaped versions in content that match unescaped find
const lines = content.split("\n")
const lines = prep.lines
const findLines = unescapedFind.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
@@ -494,36 +501,36 @@ export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
}
}
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
export const MultiOccurrenceReplacer: Replacer = function* (prep) {
// This replacer yields all exact matches, allowing the replace function
// to handle multiple occurrences based on replaceAll parameter
let startIndex = 0
while (true) {
const index = content.indexOf(find, startIndex)
const index = prep.content.indexOf(prep.find, startIndex)
if (index === -1) break
yield find
startIndex = index + find.length
yield prep.find
startIndex = index + prep.find.length
}
}
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
const trimmedFind = find.trim()
export const TrimmedBoundaryReplacer: Replacer = function* (prep) {
const trimmedFind = prep.find.trim()
if (trimmedFind === find) {
if (trimmedFind === prep.find) {
// Already trimmed, no point in trying
return
}
// Try to find the trimmed version
if (content.includes(trimmedFind)) {
if (prep.content.includes(trimmedFind)) {
yield trimmedFind
}
// Also try finding blocks where trimmed content matches
const lines = content.split("\n")
const findLines = find.split("\n")
const lines = prep.lines
const findLines = prep.finds
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
@@ -534,19 +541,13 @@ export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
}
}
export const ContextAwareReplacer: Replacer = function* (content, find) {
const findLines = find.split("\n")
export const ContextAwareReplacer: Replacer = function* (prep) {
const findLines = trimLastEmpty(prep.finds)
if (findLines.length < 3) {
// Need at least 3 lines to have meaningful context
return
}
// Remove trailing empty line if present
if (findLines[findLines.length - 1] === "") {
findLines.pop()
}
const contentLines = content.split("\n")
const contentLines = prep.lines
// Extract first and last lines as context anchors
const firstLine = findLines[0].trim()
@@ -635,6 +636,13 @@ export function replace(content: string, oldString: string, newString: string, r
let notFound = true
const prep = {
content,
find: oldString,
lines: content.split("\n"),
finds: oldString.split("\n"),
} satisfies Prep
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
@@ -646,7 +654,7 @@ export function replace(content: string, oldString: string, newString: string, r
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
for (const search of replacer(prep)) {
const index = content.indexOf(search)
if (index === -1) continue
notFound = false

View File

@@ -17,6 +17,8 @@ const MAX_LINE_LENGTH = 2000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
const MAX_ATTACHMENT_LABEL = `${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
@@ -122,6 +124,9 @@ export const ReadTool = Tool.define("read", {
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
const isPdf = mime === "application/pdf"
if (isImage || isPdf) {
if (Number(stat.size) > MAX_ATTACHMENT_BYTES) {
throw new Error(`Cannot attach file larger than ${MAX_ATTACHMENT_LABEL}: ${filepath}`)
}
const msg = `${isImage ? "Image" : "PDF"} read successfully`
return {
title,
@@ -167,7 +172,7 @@ export const ReadTool = Tool.define("read", {
if (raw.length >= limit) {
hasMoreLines = true
continue
break
}
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
@@ -198,7 +203,6 @@ export const ReadTool = Tool.define("read", {
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
output += content.join("\n")
const totalLines = lines
const lastReadLine = offset + raw.length - 1
const nextOffset = lastReadLine + 1
const truncated = hasMoreLines || truncatedByBytes
@@ -206,9 +210,9 @@ export const ReadTool = Tool.define("read", {
if (truncatedByBytes) {
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
} else if (hasMoreLines) {
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
output += `\n\n(Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
} else {
output += `\n\n(End of file - total ${totalLines} lines)`
output += `\n\n(End of file - total ${lines} lines)`
}
output += "\n</content>"

View File

@@ -1,26 +0,0 @@
import { describe, expect, test } from "bun:test"
import stripAnsi from "strip-ansi"
import { formatAccountLabel, formatOrgLine } from "../../src/cli/cmd/account"
describe("console account display", () => {
test("includes the account url in account labels", () => {
expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, false))).toBe(
"one@example.com https://one.example.com",
)
})
test("includes the active marker in account labels", () => {
expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, true))).toBe(
"one@example.com https://one.example.com (active)",
)
})
test("includes the account url in org rows", () => {
expect(
stripAnsi(
formatOrgLine({ email: "one@example.com", url: "https://one.example.com" }, { id: "org-1", name: "One" }, true),
),
).toBe(" ● One one@example.com https://one.example.com org-1")
})
})

View File

@@ -1,402 +0,0 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { Effect, Exit, Layer, Stream } from "effect"
import type * as PlatformError from "effect/PlatformError"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
const fx = testEffect(live)
function js(code: string, opts?: ChildProcess.CommandOptions) {
return ChildProcess.make("node", ["-e", code], opts)
}
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
return Stream.runCollect(stream).pipe(
Effect.map((chunks) => {
const total = chunks.reduce((acc, x) => acc + x.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const chunk of chunks) {
out.set(chunk, off)
off += chunk.length
}
return new TextDecoder("utf-8").decode(out).trim()
}),
)
}
function alive(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function gone(pid: number, timeout = 5_000) {
const end = Date.now() + timeout
while (Date.now() < end) {
if (!alive(pid)) return true
await Bun.sleep(50)
}
return !alive(pid)
}
describe("cross-spawn spawner", () => {
describe("basic spawning", () => {
fx.effect(
"captures stdout",
Effect.gen(function* () {
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])),
)
expect(out).toBe("ok")
}),
)
fx.effect(
"captures multiple lines",
Effect.gen(function* () {
const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")')
const out = yield* decodeByteStream(handle.stdout)
expect(out).toBe("line1\nline2\nline3")
}),
)
fx.effect(
"returns exit code",
Effect.gen(function* () {
const handle = yield* js("process.exit(0)")
const code = yield* handle.exitCode
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
fx.effect(
"returns non-zero exit code",
Effect.gen(function* () {
const handle = yield* js("process.exit(42)")
const code = yield* handle.exitCode
expect(code).toBe(ChildProcessSpawner.ExitCode(42))
}),
)
})
describe("cwd option", () => {
fx.effect(
"uses cwd when spawning commands",
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
),
)
expect(out).toBe(tmp.path)
}),
)
fx.effect(
"fails for invalid cwd",
Effect.gen(function* () {
const exit = yield* Effect.exit(
ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(),
)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
describe("env option", () => {
fx.effect(
"passes environment variables with extendEnv",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', {
env: { TEST_VAR: "test_value" },
extendEnv: true,
})
const out = yield* decodeByteStream(handle.stdout)
expect(out).toBe("test_value")
}),
)
fx.effect(
"passes multiple environment variables",
Effect.gen(function* () {
const handle = yield* js(
"process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)",
{
env: { VAR1: "one", VAR2: "two", VAR3: "three" },
extendEnv: true,
},
)
const out = yield* decodeByteStream(handle.stdout)
expect(out).toBe("one-two-three")
}),
)
})
describe("stderr", () => {
fx.effect(
"captures stderr output",
Effect.gen(function* () {
const handle = yield* js('process.stderr.write("error message")')
const err = yield* decodeByteStream(handle.stderr)
expect(err).toBe("error message")
}),
)
fx.effect(
"captures both stdout and stderr",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
expect(stdout).toBe("stdout")
expect(stderr).toBe("stderr")
}),
)
})
describe("combined output (all)", () => {
fx.effect(
"captures stdout via .all when no stderr",
Effect.gen(function* () {
const handle = yield* ChildProcess.make("echo", ["hello from stdout"])
const all = yield* decodeByteStream(handle.all)
expect(all).toBe("hello from stdout")
}),
)
fx.effect(
"captures stderr via .all when no stdout",
Effect.gen(function* () {
const handle = yield* js('process.stderr.write("hello from stderr")')
const all = yield* decodeByteStream(handle.all)
expect(all).toBe("hello from stderr")
}),
)
})
describe("stdin", () => {
fx.effect(
"allows providing standard input to a command",
Effect.gen(function* () {
const input = "a b c"
const stdin = Stream.make(Buffer.from(input, "utf-8"))
const handle = yield* js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
{ stdin },
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("a b c")
}),
)
})
describe("process control", () => {
fx.effect(
"kills a running process",
Effect.gen(function* () {
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* js("setTimeout(() => {}, 10_000)")
yield* handle.kill()
return yield* handle.exitCode
}),
)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
fx.effect(
"kills a child when scope exits",
Effect.gen(function* () {
const pid = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* js("setInterval(() => {}, 10_000)")
return Number(handle.pid)
}),
)
const done = yield* Effect.promise(() => gone(pid))
expect(done).toBe(true)
}),
)
fx.effect(
"forceKillAfter escalates for stubborn processes",
Effect.gen(function* () {
if (process.platform === "win32") return
const started = Date.now()
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
yield* handle.kill({ forceKillAfter: 100 })
return yield* handle.exitCode
}),
)
expect(Date.now() - started).toBeLessThan(1_000)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
fx.effect(
"isRunning reflects process state",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("done")')
yield* handle.exitCode
const running = yield* handle.isRunning
expect(running).toBe(false)
}),
)
})
describe("error handling", () => {
fx.effect(
"fails for invalid command",
Effect.gen(function* () {
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* ChildProcess.make("nonexistent-command-12345")
return yield* handle.exitCode
}),
)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
})
describe("pipeline", () => {
fx.effect(
"pipes stdout of one command to stdin of another",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("hello world")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
),
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("HELLO WORLD")
}),
)
fx.effect(
"three-stage pipeline",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("hello world")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
),
),
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))',
),
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("HELLO-WORLD")
}),
)
fx.effect(
"pipes stderr with { from: 'stderr' }",
Effect.gen(function* () {
const handle = yield* js('process.stderr.write("error")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
),
{ from: "stderr" },
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("error")
}),
)
fx.effect(
"pipes combined output with { from: 'all' }",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
),
{ from: "all" },
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toContain("stdout")
expect(out).toContain("stderr")
}),
)
})
describe("Windows-specific", () => {
fx.effect(
"uses shell routing on Windows",
Effect.gen(function* () {
if (process.platform !== "win32") return
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(
ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], {
shell: true,
extendEnv: true,
env: { OPENCODE_TEST_SHELL: "ok" },
}),
),
)
expect(out).toContain("OPENCODE_TEST_SHELL=ok")
}),
)
fx.effect(
"runs cmd scripts with spaces on Windows without shell",
Effect.gen(function* () {
if (process.platform !== "win32") return
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const dir = path.join(tmp.path, "with space")
const file = path.join(dir, "echo cmd.cmd")
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n"))
const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.exitCode(
ChildProcess.make(file, ["--stdio"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}),
),
)
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
})
})

View File

@@ -54,19 +54,3 @@ describe("plugin.auth-override", () => {
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000) // Increased timeout for plugin installation
})
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
describe("plugin.config-hook-error-isolation", () => {
test("config hooks are individually error-isolated in the layer factory", async () => {
const src = await Bun.file(file).text()
// The config hook try/catch lives in the InstanceState factory (layer definition),
// not in init() which now just delegates to the Effect service.
expect(src).toContain("plugin config hook failed")
const pattern =
/for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?\.config\?\.\([\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/
expect(pattern.test(src)).toBe(true)
})
})

View File

@@ -1,69 +1,78 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, mock, test } from "bun:test"
import { Project } from "../../src/project/project"
import { Log } from "../../src/util/log"
import { $ } from "bun"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Filesystem } from "../../src/util/filesystem"
import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Effect, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
Log.init({ print: false })
const encoder = new TextEncoder()
const gitModule = await import("../../src/util/git")
const originalGit = gitModule.git
/**
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
* else to the real CrossSpawnSpawner.
*/
function mockGitFailure(failArg: string) {
return Layer.effect(
ChildProcessSpawner.ChildProcessSpawner,
Effect.gen(function* () {
const real = yield* ChildProcessSpawner.ChildProcessSpawner
return ChildProcessSpawner.make(
Effect.fnUntraced(function* (command) {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
if (std?.command === "git" && std.args.some((a) => a === failArg)) {
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
stdout: Stream.empty,
stderr: Stream.make(encoder.encode("fatal: simulated failure\n")),
all: Stream.empty,
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
getOutputFd: () => Stream.empty,
})
}
return yield* real.spawn(command)
}),
)
}),
).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
let mode: Mode = "none"
mock.module("../../src/util/git", () => ({
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
const cmd = ["git", ...args].join(" ")
if (
mode === "rev-list-fail" &&
cmd.includes("git rev-list") &&
cmd.includes("--max-parents=0") &&
cmd.includes("HEAD")
) {
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
return originalGit(args, opts)
},
}))
async function withMode(next: Mode, run: () => Promise<void>) {
const prev = mode
mode = next
try {
await run()
} finally {
mode = prev
}
}
function projectLayerWithFailure(failArg: string) {
return Project.layer.pipe(
Layer.provide(mockGitFailure(failArg)),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
async function loadProject() {
return (await import("../../src/project/project")).Project
}
describe("Project.fromDirectory", () => {
test("should handle git repository with no commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await p.fromDirectory(tmp.path)
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
@@ -71,13 +80,15 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode")
expect(await Bun.file(opencodeFile).exists()).toBe(false)
const fileExists = await Filesystem.exists(opencodeFile)
expect(fileExists).toBe(false)
})
test("should handle git repository with commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await p.fromDirectory(tmp.path)
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
@@ -85,63 +96,54 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode")
expect(await Bun.file(opencodeFile).exists()).toBe(true)
const fileExists = await Filesystem.exists(opencodeFile)
expect(fileExists).toBe(true)
})
test("returns global for non-git directory", async () => {
await using tmp = await tmpdir()
const { project } = await Project.fromDirectory(tmp.path)
expect(project.id).toBe(ProjectID.global)
})
test("derives stable project ID from root commit", async () => {
await using tmp = await tmpdir({ git: true })
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(tmp.path)
expect(b.id).toBe(a.id)
})
})
describe("Project.fromDirectory git failure paths", () => {
test("keeps vcs when rev-list exits non-zero (no commits)", async () => {
test("keeps git vcs when rev-list exits non-zero with empty output", async () => {
const p = await loadProject()
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
// rev-list fails because HEAD doesn't exist yet — this is the natural scenario
const { project } = await Project.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path)
await withMode("rev-list-fail", async () => {
const { project } = await p.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path)
})
})
test("handles show-toplevel failure gracefully", async () => {
test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--show-toplevel")
const { project, sandbox } = await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
await withMode("top-fail", async () => {
const { project, sandbox } = await p.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
})
test("handles git-common-dir failure gracefully", async () => {
test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--git-common-dir")
const { project, sandbox } = await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
await withMode("common-dir-fail", async () => {
const { project, sandbox } = await p.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
})
})
describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project, sandbox } = await Project.fromDirectory(tmp.path)
const { project, sandbox } = await p.fromDirectory(tmp.path)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
@@ -149,13 +151,14 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("should set worktree to root when called from a worktree", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
try {
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
const { project, sandbox } = await Project.fromDirectory(worktreePath)
const { project, sandbox } = await p.fromDirectory(worktreePath)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(worktreePath)
@@ -170,21 +173,22 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("worktree should share project ID with main repo", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project: main } = await Project.fromDirectory(tmp.path)
const { project: main } = await p.fromDirectory(tmp.path)
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const { project: wt } = await Project.fromDirectory(worktreePath)
const { project: wt } = await p.fromDirectory(worktreePath)
expect(wt.id).toBe(main.id)
// Cache should live in the common .git dir, not the worktree's .git file
const cache = path.join(tmp.path, ".git", "opencode")
const exists = await Bun.file(cache).exists()
const exists = await Filesystem.exists(cache)
expect(exists).toBe(true)
} finally {
await $`git worktree remove ${worktreePath}`
@@ -195,6 +199,7 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("separate clones of the same repo should share project ID", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
// Create a bare remote, push, then clone into a second directory
@@ -204,8 +209,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet()
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(clone)
const { project: a } = await p.fromDirectory(tmp.path)
const { project: b } = await p.fromDirectory(clone)
expect(b.id).toBe(a.id)
} finally {
@@ -214,6 +219,7 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
@@ -222,8 +228,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
await Project.fromDirectory(worktree1)
const { project } = await Project.fromDirectory(worktree2)
await p.fromDirectory(worktree1)
const { project } = await p.fromDirectory(worktree2)
expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1)
@@ -244,13 +250,14 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => {
test("should discover favicon.png in root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await p.fromDirectory(tmp.path)
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await Project.discover(project)
await p.discover(project)
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -261,12 +268,13 @@ describe("Project.discover", () => {
})
test("should not discover non-image files", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await p.fromDirectory(tmp.path)
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
await Project.discover(project)
await p.discover(project)
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -336,6 +344,8 @@ describe("Project.update", () => {
})
test("should throw error when project not found", async () => {
await using tmp = await tmpdir({ git: true })
await expect(
Project.update({
projectID: ProjectID.make("nonexistent-project-id"),
@@ -348,24 +358,22 @@ describe("Project.update", () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
let eventFired = false
let eventPayload: any = null
const on = (data: any) => {
GlobalBus.on("event", (data) => {
eventFired = true
eventPayload = data
}
GlobalBus.on("event", on)
})
try {
await Project.update({
projectID: project.id,
name: "Updated Name",
})
await Project.update({
projectID: project.id,
name: "Updated Name",
})
expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated")
expect(eventPayload.payload.properties.name).toBe("Updated Name")
} finally {
GlobalBus.off("event", on)
}
expect(eventFired).toBe(true)
expect(eventPayload.payload.type).toBe("project.updated")
expect(eventPayload.payload.properties.name).toBe("Updated Name")
})
test("should update multiple fields at once", async () => {
@@ -385,75 +393,3 @@ describe("Project.update", () => {
expect(updated.commands?.start).toBe("make start")
})
})
describe("Project.list and Project.get", () => {
test("list returns all projects", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
expect(all.find((p) => p.id === project.id)).toBeDefined()
})
test("get returns project by id", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const found = Project.get(project.id)
expect(found).toBeDefined()
expect(found!.id).toBe(project.id)
})
test("get returns undefined for unknown id", () => {
const found = Project.get(ProjectID.make("nonexistent"))
expect(found).toBeUndefined()
})
})
describe("Project.setInitialized", () => {
test("sets time_initialized on project", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
expect(project.time.initialized).toBeUndefined()
Project.setInitialized(project.id)
const updated = Project.get(project.id)
expect(updated?.time.initialized).toBeDefined()
})
})
describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const sandboxDir = path.join(tmp.path, "sandbox-test")
await Project.addSandbox(project.id, sandboxDir)
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
await Project.removeSandbox(project.id, sandboxDir)
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
})
test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const sandboxDir = path.join(tmp.path, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
await Project.addSandbox(project.id, sandboxDir)
GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
})
})

View File

@@ -1629,43 +1629,6 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
})
})
describe("ProviderTransform.message - bedrock caching with non-bedrock providerID", () => {
test("applies cache options at message level when npm package is amazon-bedrock", () => {
const model = {
id: "aws/us.anthropic.claude-opus-4-6-v1",
providerID: "aws",
api: {
id: "us.anthropic.claude-opus-4-6-v1",
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
name: "Claude Opus 4.6",
capabilities: {},
options: {},
headers: {},
} as any
const msgs = [
{
role: "system",
content: [{ type: "text", text: "You are a helpful assistant" }],
},
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
] as any[]
const result = ProviderTransform.message(msgs, model, {}) as any[]
// Cache should be at the message level and not the content-part level
expect(result[0].providerOptions?.bedrock).toEqual({
cachePoint: { type: "default" },
})
expect(result[0].content[0].providerOptions?.bedrock).toBeUndefined()
})
})
describe("ProviderTransform.message - cache control on gateway", () => {
const createModel = (overrides: Partial<any> = {}) =>
({

View File

@@ -117,16 +117,3 @@ describe("session messages endpoint", () => {
})
})
})
describe("session.prompt_async error handling", () => {
test("prompt_async route has error handler for detached prompt call", async () => {
const src = await Bun.file(path.join(import.meta.dir, "../../src/server/routes/session.ts")).text()
const start = src.indexOf('"/:sessionID/prompt_async"')
const end = src.indexOf('"/:sessionID/command"', start)
expect(start).toBeGreaterThan(-1)
expect(end).toBeGreaterThan(start)
const route = src.slice(start, end)
expect(route).toContain(".catch(")
expect(route).toContain("Bus.publish(Session.Event.Error")
})
})

View File

@@ -1,6 +1,5 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { NamedError } from "@opencode-ai/util/error"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { ModelID, ProviderID } from "../../src/provider/schema"
@@ -211,78 +210,3 @@ describe("session.prompt agent variant", () => {
}
})
})
describe("session.agent-resolution", () => {
test("unknown agent throws typed error", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
},
})
}, 30000)
test("unknown agent error includes available agent names", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
},
})
}, 30000)
test("unknown command throws typed error with available names", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.command({
sessionID: session.id,
command: "nonexistent-command-xyz",
arguments: "",
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
},
})
}, 30000)
})

View File

@@ -236,7 +236,7 @@ describe("tool.read truncation", () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Showing lines 1-10 of 100")
expect(result.output).toContain("Showing lines 1-10. Use offset=11")
expect(result.output).toContain("Use offset=11")
expect(result.output).toContain("line0")
expect(result.output).toContain("line9")
@@ -418,6 +418,23 @@ describe("tool.read truncation", () => {
})
})
test("rejects oversized image attachments", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "huge.png"), Buffer.alloc(6 * 1024 * 1024, 0))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "huge.png") }, ctx)).rejects.toThrow(
"Cannot attach file larger than 5 MB",
)
},
})
})
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.3.2",
"version": "1.3.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.3.2",
"version": "1.3.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.3.2",
"version": "1.3.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.3.2",
"version": "1.3.0",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -142,7 +142,6 @@ export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
messages?: MessageType[]
actions?: UserActions
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
@@ -167,7 +166,7 @@ export function SessionTurn(
const emptyDiffs: FileDiff[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
const messageIndex = createMemo(() => {
const messages = allMessages() ?? emptyMessages
@@ -341,28 +340,30 @@ export function SessionTurn(
if (end < start) return undefined
return end - start
})
const assistantDerived = createMemo(() => {
let visible = 0
let tail: "text" | "other" | undefined
let reason: string | undefined
const show = showReasoningSummaries()
for (const message of assistantMessages()) {
for (const part of list(data.store.part?.[message.id], emptyParts)) {
if (partState(part, show) === "visible") {
visible++
tail = part.type === "text" ? "text" : "other"
}
if (part.type === "reasoning" && part.text) {
const h = heading(part.text)
if (h) reason = h
}
}
}
return { visible, tail, reason }
})
const assistantVisible = createMemo(() => assistantDerived().visible)
const assistantTailVisible = createMemo(() => assistantDerived().tail)
const reasoningHeading = createMemo(() => assistantDerived().reason)
const assistantVisible = createMemo(() =>
assistantMessages().reduce((count, message) => {
const parts = list(data.store.part?.[message.id], emptyParts)
return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length
}, 0),
)
const assistantTailVisible = createMemo(() =>
assistantMessages()
.flatMap((message) => list(data.store.part?.[message.id], emptyParts))
.flatMap((part) => {
if (partState(part, showReasoningSummaries()) !== "visible") return []
if (part.type === "text") return ["text" as const]
return ["other" as const]
})
.at(-1),
)
const reasoningHeading = createMemo(() =>
assistantMessages()
.flatMap((message) => list(data.store.part?.[message.id], emptyParts))
.filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning")
.map((part) => heading(part.text))
.filter((text): text is string => !!text)
.at(-1),
)
const showThinking = createMemo(() => {
if (!working() || !!error()) return false
if (status().type === "retry") return false

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.3.2",
"version": "1.3.0",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.3.2",
"version": "1.3.0",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -2,19 +2,18 @@
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { buildNotes, getLatestRelease } from "./changelog"
const output = [`version=${Script.version}`]
if (!Script.preview) {
await $`opencode run --command changelog`.cwd(process.cwd())
const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
const body = await Bun.file(file)
.text()
.catch(() => "No notable changes")
const previous = await getLatestRelease()
const notes = await buildNotes(previous, "HEAD")
const body = notes.join("\n") || "No notable changes"
const dir = process.env.RUNNER_TEMP ?? "/tmp"
const notesFile = `${dir}/opencode-release-notes.txt`
await Bun.write(notesFile, body)
await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${notesFile}`
const file = `${dir}/opencode-release-notes.txt`
await Bun.write(file, body)
await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${file}`
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
output.push(`release=${release.databaseId}`)
output.push(`tag=${release.tagName}`)

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.3.2",
"version": "1.3.0",
"publisher": "sst-dev",
"repository": {
"type": "git",