Compare commits

...
Author SHA1 Message Date
James Long 5668fe61f8 be smarter about generating a worktree name 2026-05-08 11:42:22 -04:00
James Long ed6140af9e fix tests 2026-05-08 11:02:57 -04:00
James Long 8d63dc34c0 fix types 2026-05-08 10:45:00 -04:00
James Long 771170fb03 feat(core): allow external workspace creation 2026-05-07 13:16:43 -04:00
Dax Raad a300a6cc7a rebase migrations properly 2026-05-07 12:38:29 -04:00
opencode fe594693a4 sync release versions for v1.14.41 2026-05-07 14:52:09 +00:00
opencode-agent[bot] 98e091796b chore: generate 2026-05-07 14:25:30 +00:00
James LongandGitHub 3c4b4d5faf feat(core): copy file changes when warping (#26190) 2026-05-07 10:24:17 -04:00
vimtor b6ff1b18c7 chore: activate free tier requests query 2026-05-07 14:10:47 +02:00
vimtor 9c9bc09f52 chore: fix free tier query 2026-05-07 13:58:41 +02:00
vimtor d6e06c8950 chore: fix free tier query 2026-05-07 13:41:14 +02:00
opencode-agent[bot] 844fb71938 chore: generate 2026-05-07 11:37:34 +00:00
vimtor fbb7b5b1bf chore: add free tier usage alert 2026-05-07 13:36:18 +02:00
Shoubhit DashandGitHub 95280ebec9 fix(tui): restore custom provider in /connect (#26168) 2026-05-07 17:05:35 +05:30
YGoetschelandGitHub fea9a0bd4c fix: guard undefined contents in diff renderer to fix share viewer SSR crash (#21763) 2026-05-07 18:55:40 +08:00
vimtor 30c4fcb1a5 chore: fix honeycomb query frequency 2026-05-07 11:10:36 +02:00
vimtor 193c169ca5 chore: improve provider down query 2026-05-07 11:00:43 +02:00
vimtor cee04f2924 chore: make provider down queries live 2026-05-07 10:56:37 +02:00
Brendan AllanandGitHub 1219691c11 docs(desktop): update README from Tauri to Electron (#26146) 2026-05-07 16:31:37 +08:00
vimtor b2cc40f09c chore: first provider alert version 2026-05-07 10:30:29 +02:00
vimtor 0b2e65f16d chore: reactivate alerts 2026-05-07 10:15:52 +02:00
opencode-agent[bot] 1ea01fdad0 chore: update nix node_modules hashes 2026-05-07 08:08:37 +00:00
Victor NavarroandGitHub f8aa4a3be0 chore: simplify honeycomb alerts (#26142) 2026-05-07 07:56:10 +00:00
293bb422fa fix(format): restore stdout/stderr ignore for formatter processes (#26037)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-05-07 00:52:07 -05:00
54a78c9224 feat(desktop): move server to utilityProcess (#25962)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-05-07 13:48:56 +08:00
9b30ee2db2 fix(desktop): add macOS settings menu entry (#26081)
Co-authored-by: jesse.mahnken <jesse.mahnken@tiefox.de>
2026-05-07 13:39:14 +08:00
carmit hershmanandGitHub ba1ec62caf docs: add opencode-jfrog-plugin to ecosystem list for JFrog integration (#26019) 2026-05-07 00:37:14 -05:00
Frank 72ec05d0be go: rate limit metadata 2026-05-07 00:32:33 -04:00
Frank 0b702704ae zen: nano not used for title gen 2026-05-06 23:01:16 -04:00
opencode-agent[bot] 3480cef52e chore: update nix node_modules hashes 2026-05-07 00:46:33 +00:00
opencode dcfe4b0d51 sync release versions for v1.14.40 2026-05-07 00:34:09 +00:00
Aiden ClineandGitHub b2e3dc87ea feat: Update ACP support, modernize and fix misc issues (#25663) 2026-05-06 19:33:52 -05:00
107 changed files with 4466 additions and 850 deletions
-1
View File
@@ -37,7 +37,6 @@ jobs:
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
INCIDENT_API_KEY: ${{ secrets.INCIDENT_API_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
+1 -1
View File
@@ -3,7 +3,7 @@
"provider": {},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
"packages/opencode/migration/*": "ask",
},
},
"mcp": {},
+17 -26
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,6 @@
"solid-js": "catalog:",
"solid-list": "0.3.0",
"solid-stripe": "0.8.1",
"svix": "1.92.2",
"vite": "catalog:",
"zod": "catalog:",
},
@@ -120,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -147,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -171,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -195,7 +194,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.39",
"version": "1.14.41",
"bin": {
"opencode": "./bin/opencode",
},
@@ -229,7 +228,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -283,7 +282,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -312,7 +311,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -328,14 +327,14 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.39",
"version": "1.14.41",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/anthropic": "3.0.71",
@@ -470,7 +469,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -505,7 +504,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -520,7 +519,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -555,7 +554,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -604,7 +603,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -728,7 +727,7 @@
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
@@ -2168,8 +2167,6 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="],
"@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="],
@@ -3180,8 +3177,6 @@
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
@@ -4656,8 +4651,6 @@
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@@ -4726,8 +4719,6 @@
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"svix": ["svix@1.92.2", "", { "dependencies": { "standardwebhooks": "1.0.0" } }, "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ=="],
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
+1
View File
@@ -30,6 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", {
transform: {
worker: (args) => {
args.logpush = true
if ($app.stage === "vimtor") return
args.bindings = $resolve(args.bindings).apply((bindings) => [
...bindings,
{
+3 -3
View File
@@ -1,5 +1,6 @@
import { domain } from "./stage"
import { EMAILOCTOPUS_API_KEY } from "./app"
import { SECRET } from "./secret"
////////////////
// DATABASE
@@ -221,8 +222,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret },
})
const INCIDENT_WEBHOOK_SIGNING_SECRET = new sst.Secret("INCIDENT_WEBHOOK_SIGNING_SECRET")
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
@@ -233,6 +232,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
const bucket = new sst.cloudflare.Bucket("ZenData")
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
@@ -254,8 +254,8 @@ new sst.cloudflare.x.SolidStart("Console", {
database,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
INCIDENT_WEBHOOK_SIGNING_SECRET,
DISCORD_INCIDENT_WEBHOOK_URL,
SECRET.HoneycombWebhookSecret,
STRIPE_SECRET_KEY,
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
+167 -286
View File
@@ -1,318 +1,199 @@
const displayName = (s: string) =>
s
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
.replace(/(?<=\d) (?=\d)/g, ".")
import { SECRET } from "./secret"
import { domain } from "./stage"
const resourceName = (s: string) => displayName(s).replace(/[^a-zA-Z0-9]/g, "")
const varSpec = (label: string, name: string) =>
$jsonStringify({
content: [
{
content: [
{
attrs: {
name,
label,
missing: false,
},
type: "varSpec",
},
],
type: "paragraph",
},
],
type: "doc",
})
const fields = {
model: incident.getAlertAttributeOutput({ name: "Model" }),
product: incident.getAlertAttributeOutput({ name: "Product" }),
}
const alertSource = new incident.AlertSource("HoneycombAlertSource", {
name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`,
sourceType: "honeycomb",
template: {
title: {
literal: varSpec("Payload -> Title", "title"),
},
description: {
literal: varSpec("Payload -> Description", "description"),
},
attributes: [
{
alertAttributeId: fields.model.id,
binding: {
value: {
reference: 'expressions["model"]',
},
mergeStrategy: "first_wins",
},
},
{
alertAttributeId: fields.product.id,
binding: {
value: {
reference: 'expressions["product"]',
},
mergeStrategy: "first_wins",
},
},
],
expressions: [
{
label: "Model",
operations: [
{
operationType: "parse",
parse: {
returns: {
array: false,
type: fields.model.type,
},
source: "$['model']",
},
},
],
reference: "model",
rootReference: "payload",
},
{
label: "Product",
operations: [
{
operationType: "parse",
parse: {
returns: {
array: false,
type: fields.product.type,
},
source: "$['product']",
},
},
],
reference: "product",
rootReference: "payload",
},
],
},
})
const webhookRecipient = new honeycomb.WebhookRecipient(`IncidentWebhook`, {
name: $app.stage === "production" ? "Incident.io" : `Incident.io (${$app.stage})`,
url: alertSource.alertEventsUrl,
secret: alertSource.secretToken,
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
url: `https://${domain}/honeycomb/webhook`,
secret: SECRET.HoneycombWebhookSecret.result,
templates: [
{
type: "trigger",
body: $jsonStringify({
title: "{{ .Name }}",
description: "{{ .Description }}",
status: "{{ .Alert.Status }}",
deduplication_key: "{{ .Alert.InstanceID }}",
source_url: "{{ .Result.URL }}",
model: "{{ .Vars.model }}",
product: "{{ .Vars.product }}",
}),
body: `{
"url": {{ .Result.URL | quote }},
"type": {{ .Vars.type | quote }},
"name": {{ .Name | quote }},
"status": {{ .Alert.Status | quote }},
"isTest": {{ .Alert.IsTest }},
"groups": {{ .Result.GroupsTriggered | toJson }}
}`,
},
],
variables: [
{
name: "model",
},
{
name: "product",
name: "type",
},
],
})
new incident.AlertRoute("HoneycombAlertRoute", {
name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`,
enabled: true,
isPrivate: false,
alertSources: [
{
alertSourceId: alertSource.id,
conditionGroups: [
{
conditions: [
{
subject: "alert.title",
operation: "is_set",
paramBindings: [],
},
],
},
],
},
],
conditionGroups: [
{
conditions: [
{
subject: "alert.title",
operation: "is_set",
paramBindings: [],
},
],
},
],
expressions: [],
escalationConfig: {
autoCancelEscalations: true,
escalationTargets: [],
},
incidentConfig: {
autoDeclineEnabled: true,
enabled: true,
conditionGroups: [],
deferTimeSeconds: 0,
groupingKeys: [
const modelHttpErrorsQuery = (product: "go" | "zen") => {
const filters = [
{ column: "model", op: "exists" },
{ column: "event_type", op: "=", value: "completions" },
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
]
return honeycomb.getQuerySpecificationOutput({
breakdowns: ["model"],
calculatedFields: [
{
reference: $interpolate`alert.attributes.${fields.model.id}`,
},
{
reference: $interpolate`alert.attributes.${fields.product.id}`,
name: "is_failed_http_status",
expression: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
},
],
groupingWindowSeconds: 3600,
},
incidentTemplate: {
name: {
value: {
literal: varSpec("Alert -> Title", "alert.title"),
},
},
summary: {
value: {
literal: varSpec("Alert -> Description", "alert.description"),
},
},
startInTriage: {
value: {
literal: "true",
},
},
severity: {
mergeStrategy: "first-wins",
},
incidentMode: {
value: {
literal: $app.stage === "production" ? "standard" : "test",
},
},
},
})
type Product = "go" | "zen"
type Trigger = (opts: { model: string; product: Product }) => {
id: string
title: string
description: string
json: honeycomb.GetQuerySpecificationOutputArgs
threshold: { op: ">=" | "<="; value: number }
calculations: [
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
{ op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters },
],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 500), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900,
}).json
}
type Model = { id: string; products: Product[]; triggers: Trigger[] }
const providerHttpErrorsQuery = (product: "go" | "zen") => {
const filters = [
{ column: "provider", op: "exists" },
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
]
const httpErrors: Trigger = ({ model, product }) => ({
id: "increased-http-errors",
title: `Increased HTTP Errors for ${displayName(model)} on ${displayName(product)}`,
description: `Detected increased rate of HTTP errors for ${displayName(model)} on OpenCode ${displayName(product)}`,
json: {
return honeycomb.getQuerySpecificationOutput({
breakdowns: ["provider"],
calculatedFields: [
{
name: "is_success_http_status",
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
},
{
name: "is_failed_provider_http_status",
expression: `IF(GTE($llm.error.code, "400"), 1, 0)`,
},
],
calculations: [
{
op: "COUNT",
name: "TOTAL",
op: "SUM",
name: "SUCCESS",
column: "is_success_http_status",
filterCombination: "AND",
filters: [
{ column: "model", op: "=", value: model },
{ column: "event_type", op: "=", value: "completions" },
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
],
filters: [...filters, { column: "event_type", op: "=", value: "completions" }],
},
{
op: "COUNT",
op: "SUM",
name: "FAILED",
column: "is_failed_provider_http_status",
filterCombination: "AND",
filters: [
{ column: "model", op: "=", value: model },
{ column: "event_type", op: "=", value: "completions" },
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
{ column: "status", op: ">=", value: "400" },
{ column: "status", op: "!=", value: "401" },
],
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
},
],
formulas: [{ name: "ERROR", expression: "$FAILED / $TOTAL" }],
timeRange: 900,
},
threshold: { op: ">=", value: 0.8 },
formulas: [
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 250), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
],
timeRange: 1800,
}).json
}
const description = "Managed by SST (Don't edit in Honeycomb UI)"
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]",
description,
queryJson: modelHttpErrorsQuery("go"),
alertType: "on_change",
frequency: 300,
thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "model_http_errors" }],
},
],
},
],
})
const models: Model[] = [
{ id: "kimi-k2.6", products: ["go", "zen"], triggers: [httpErrors] },
{ id: "kimi-k2.5", products: ["go", "zen"], triggers: [httpErrors] },
{ id: "deepseek-v4-flash", products: ["go", "zen"], triggers: [httpErrors] },
{ id: "deepseek-v4-pro", products: ["go", "zen"], triggers: [httpErrors] },
{ id: "glm-5.1", products: ["go", "zen"], triggers: [httpErrors] },
// { id: "glm-5", products: ["go"], triggers: [httpErrors] },
{ id: "qwen3.6-plus", products: ["go", "zen"], triggers: [httpErrors] },
{ id: "qwen3.5-plus", products: ["go"], triggers: [httpErrors] },
{ id: "minimax-m2.7", products: ["go", "zen"], triggers: [httpErrors] },
// { id: "minimax-m2.5", products: ["go", "zen"], triggers: [httpErrors] },
{ id: "mimo-v2.5-pro", products: ["go"], triggers: [httpErrors] },
// { id: "mimo-v2.5", products: ["go"], triggers: [httpErrors] },
// { id: "mimo-v2-omni", products: ["go"], triggers: [httpErrors] },
// { id: "mimo-v2-pro", products: ["go"], triggers: [httpErrors] },
{ id: "claude-opus-4-7", products: ["zen"], triggers: [httpErrors] },
// { id: "claude-opus-4-6", products: ["zen"], triggers: [httpErrors] },
// { id: "claude-sonnet-4-6", products: ["zen"], triggers: [httpErrors] },
{ id: "gpt-5.5", products: ["zen"], triggers: [httpErrors] },
{ id: "big-pickle", products: ["zen"], triggers: [httpErrors] },
// { id: "minimax-m2.5-free", products: ["zen"], triggers: [httpErrors] },
// { id: "hy3-preview-free", products: ["zen"], triggers: [httpErrors] },
// { id: "nemotron-3-super-free", products: ["zen"], triggers: [httpErrors] },
// { id: "trinity-large-preview-free", products: ["zen"], triggers: [httpErrors] },
// { id: "ling-2.6-flash-free", products: ["zen"], triggers: [httpErrors] },
]
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
name: "Increased Model HTTP Errors [Zen]",
description,
queryJson: modelHttpErrorsQuery("zen"),
alertType: "on_change",
frequency: 300,
thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "model_http_errors" }],
},
],
},
],
})
if ($app.stage !== "production") {
models.splice(1)
}
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
name: "Increased Provider HTTP Errors [Go]",
description,
queryJson: providerHttpErrorsQuery("go"),
alertType: "on_change",
frequency: 600,
thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "provider_http_errors" }],
},
],
},
],
})
for (const model of models) {
for (const product of model.products) {
for (const trigger of model.triggers) {
const spec = trigger({ model: model.id, product })
new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", {
name: "Increased Provider HTTP Errors [Zen]",
description,
queryJson: providerHttpErrorsQuery("zen"),
alertType: "on_change",
frequency: 600,
thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "provider_http_errors" }],
},
],
},
],
})
new honeycomb.Trigger(resourceName(`${spec.id}-${product}-${model.id}`), {
name: spec.title,
description: spec.description,
queryJson: honeycomb.getQuerySpecificationOutput(spec.json).json,
alertType: "on_change",
frequency: 300,
thresholds: [{ ...spec.threshold, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [
{ name: "model", value: model.id },
{ name: "product", value: product },
],
},
],
},
],
})
}
}
}
new honeycomb.Trigger("IncreasedFreeTierRequests", {
name: "Increased Free Tier Requests",
description,
queryJson: honeycomb.getQuerySpecificationOutput({
calculations: [{ op: "COUNT" }],
filters: [
{ column: "event_type", op: "=", value: "completions" },
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isFreeTier", op: "=", value: "true" },
],
timeRange: 3600,
}).json,
alertType: "on_change",
frequency: 900,
thresholds: [{ op: ">=", value: 60, exceededLimit: 1 }],
baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "custom" }],
},
],
},
],
})
+7
View File
@@ -1,4 +1,11 @@
sst.Linkable.wrap(random.RandomPassword, (resource) => ({
properties: {
value: resource.result,
},
}))
export const SECRET = {
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-ynZFX8eCamzBuVpauYLbju/Cqbt2260JNumMUj79PKA=",
"aarch64-linux": "sha256-JCu7JZkdAAHTufWEJRV1gJErKvHFirq+qmVNIRPZ/0w=",
"aarch64-darwin": "sha256-9Dkt/poYBpLdtqA6L9pLe6GS435zFGb5rOYWE5rEnjA=",
"x86_64-darwin": "sha256-Nd5j28gAcM7+0ETBchjk9VojViHy3N/z2MkdU42YuCg="
"x86_64-linux": "sha256-MHeO1KTmjYa+V4ZBYrQq93cYpjnkGfO9e3MOWwkzjVY=",
"aarch64-linux": "sha256-EqTRG7DrdKKT7CEvnaNk5VhjTRhlZ9juP9/Nnr3dJ+g=",
"aarch64-darwin": "sha256-c8dWd8Pgp5uIAOdYbHIeGKqWfkF/l4Ze7ArYUMvTNkE=",
"x86_64-darwin": "sha256-61NpSO0AZ4iZG19RQ6zg0SJec+VQE46WJKOdRrNofT0="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.39",
"version": "1.14.41",
"description": "",
"type": "module",
"exports": {
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.39",
"version": "1.14.41",
"type": "module",
"license": "MIT",
"scripts": {
@@ -31,7 +31,6 @@
"solid-js": "catalog:",
"solid-list": "0.3.0",
"solid-stripe": "0.8.1",
"svix": "1.92.2",
"vite": "catalog:",
"zod": "catalog:"
},
@@ -0,0 +1,85 @@
import type { APIEvent } from "@solidjs/start/server"
import { z } from "zod"
import { Resource } from "@opencode-ai/console-resource"
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
const DISCORD_ALERT_ROLE_ID = "1501447160175136838"
const basePayload = z.object({
name: z.string().optional(),
status: z.string().optional(),
isTest: z.boolean().optional(),
url: z.string(),
})
const groups = z.object({ group: z.object({ key: z.string(), value: z.string() }).array() }).array()
const honeycombWebhookPayload = z.discriminatedUnion("type", [
basePayload.extend({
type: z.literal("model_http_errors"),
groups,
}),
basePayload.extend({
type: z.literal("provider_http_errors"),
groups,
}),
basePayload.extend({
type: z.literal("custom"),
}),
])
const postDiscordMessage = async (payload: z.infer<typeof honeycombWebhookPayload>) => {
const group =
payload.type === "model_http_errors" ? "model" : payload.type === "provider_http_errors" ? "provider" : undefined
const names = payload.type === "custom" ? [] : payload.groups.flatMap((item) => item.group.map((g) => g.value))
const content = [
`[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`,
group && names.length > 0 ? `Affected ${group}s:` : undefined,
...names.map((name) => `- ${name}`),
"",
`<@&${DISCORD_ALERT_ROLE_ID}>`,
]
.filter((line) => line !== undefined)
.join("\n")
return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content,
allowed_mentions: { roles: [DISCORD_ALERT_ROLE_ID] },
flags: 4,
}),
})
}
export async function POST(input: APIEvent) {
const token = input.request.headers.get("X-Honeycomb-Webhook-Token")
if (!safeEqual(token ?? "", Resource.HoneycombWebhookSecret.value)) {
console.debug("Invalid Honeycomb webhook token")
return Response.json({ message: "invalid token" }, { status: 401 })
}
const body = await input.request.json()
console.log(body, JSON.stringify(body, null, 2))
const parsed = honeycombWebhookPayload.safeParse(body)
if (!parsed.success) {
console.error(parsed.error)
return Response.json({ message: "invalid payload" }, { status: 400 })
}
if (parsed.data.status !== "TRIGGERED") {
console.debug("Skipping resolved alert Honeycomb webhook")
return Response.json({ message: "ignored" }, { status: 200 })
}
const response = await postDiscordMessage(parsed.data)
if (!response.ok) {
return Response.json({ message: "discord webhook failed" }, { status: 502 })
}
return Response.json({ message: "sent" }, { status: 200 })
}
@@ -1,77 +0,0 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { Webhook } from "svix"
const DISCORD_INCIDENT_ROLE_ID = "1501447160175136838"
type Incident = {
mode?: "test" | "standard"
name?: string
permalink?: string
summary?: string
}
type IncidentWebhookPayload = {
event_type?: string
"public_incident.incident_created_v2"?: Incident
}
const verifyWebhook = async (request: Request) => {
const body = await request.text()
try {
return new Webhook(Resource.INCIDENT_WEBHOOK_SIGNING_SECRET.value).verify(
body,
Object.fromEntries(request.headers.entries()),
) as IncidentWebhookPayload
} catch {
return undefined
}
}
const postDiscordMessage = async (incident: Incident) => {
return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: [
`**${incident.mode === "test" ? "[TEST] " : ""}${incident.name ?? "Incident has been created"}**`,
incident.summary,
"",
`<@&${DISCORD_INCIDENT_ROLE_ID}>`,
"",
incident.permalink,
]
.filter((line) => line !== undefined)
.join("\n"),
allowed_mentions: {
roles: [DISCORD_INCIDENT_ROLE_ID],
},
flags: 4,
}),
})
}
export async function POST(input: APIEvent) {
const payload = await verifyWebhook(input.request)
if (!payload) {
return Response.json({ message: "invalid signature" }, { status: 401 })
}
if (payload.event_type !== "public_incident.incident_created_v2") {
return Response.json({ message: "ignored event" }, { status: 200 })
}
const incident = payload["public_incident.incident_created_v2"]
if (!incident) {
return Response.json({ message: "missing incident" }, { status: 400 })
}
const response = await postDiscordMessage(incident)
if (!response.ok) {
return Response.json({ message: "discord webhook failed" }, { status: 502 })
}
return Response.json({ message: "sent" }, { status: 200 })
}
@@ -13,4 +13,13 @@ class LimitError extends Error {
}
export class RateLimitError extends LimitError {}
export class FreeUsageLimitError extends LimitError {}
export class SubscriptionUsageLimitError extends LimitError {}
class SubscriptionUsageLimitError extends LimitError {
workspace: string
constructor(message: string, workspace: string, retryAfter?: number) {
super(message, retryAfter)
this.workspace = workspace
}
}
export class GoUsageLimitError extends SubscriptionUsageLimitError {}
export class BlackUsageLimitError extends SubscriptionUsageLimitError {}
@@ -23,7 +23,8 @@ import {
ModelError,
RateLimitError,
FreeUsageLimitError,
SubscriptionUsageLimitError,
GoUsageLimitError,
BlackUsageLimitError,
} from "./error"
import {
buildCostChunk,
@@ -395,7 +396,8 @@ export async function handler(
if (
error instanceof RateLimitError ||
error instanceof FreeUsageLimitError ||
error instanceof SubscriptionUsageLimitError
error instanceof GoUsageLimitError ||
error instanceof BlackUsageLimitError
) {
const headers = new Headers()
if (error.retryAfter) {
@@ -404,7 +406,14 @@ export async function handler(
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
error: {
type: error.constructor.name,
message: error.message,
},
metadata:
error instanceof GoUsageLimitError || error instanceof BlackUsageLimitError
? { workspace: error.workspace }
: {},
}),
{ status: 429, headers },
)
@@ -693,10 +702,11 @@ export async function handler(
timeUpdated: sub.timeFixedUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionUsageLimitError(
throw new BlackUsageLimitError(
t("zen.api.error.subscriptionQuotaExceeded", {
retryIn: formatRetryTime(result.resetInSec),
}),
authInfo.workspaceID,
result.resetInSec,
)
}
@@ -711,10 +721,11 @@ export async function handler(
timeUpdated: sub.timeRollingUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionUsageLimitError(
throw new BlackUsageLimitError(
t("zen.api.error.subscriptionQuotaExceeded", {
retryIn: formatRetryTime(result.resetInSec),
}),
authInfo.workspaceID,
result.resetInSec,
)
}
@@ -739,8 +750,9 @@ export async function handler(
timeUpdated: sub.timeWeeklyUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionUsageLimitError(
throw new GoUsageLimitError(
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
authInfo.workspaceID,
result.resetInSec,
)
}
@@ -754,8 +766,9 @@ export async function handler(
timeSubscribed: sub.timeCreated,
})
if (result.status === "rate-limited")
throw new SubscriptionUsageLimitError(
throw new GoUsageLimitError(
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
authInfo.workspaceID,
result.resetInSec,
)
}
@@ -769,8 +782,9 @@ export async function handler(
timeUpdated: sub.timeRollingUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionUsageLimitError(
throw new GoUsageLimitError(
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
authInfo.workspaceID,
result.resetInSec,
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.39",
"version": "1.14.41",
"private": true,
"type": "module",
"license": "MIT",
+8
View File
@@ -0,0 +1,8 @@
import { timingSafeEqual } from "node:crypto"
export function safeEqual(a: string, b: string): boolean {
const encoder = new TextEncoder()
const aBytes = encoder.encode(a)
const bBytes = encoder.encode(b)
return aBytes.length === bBytes.length && timingSafeEqual(aBytes, bBytes)
}
+2 -2
View File
@@ -91,8 +91,8 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
"type": "sst.sst.Secret"
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.39",
"version": "1.14.41",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+2 -2
View File
@@ -91,8 +91,8 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
"type": "sst.sst.Secret"
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.39",
"version": "1.14.41",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+2 -2
View File
@@ -91,8 +91,8 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
"type": "sst.sst.Secret"
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.39",
"version": "1.14.41",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+5 -18
View File
@@ -1,32 +1,19 @@
# OpenCode Desktop
Native OpenCode desktop app, built with Tauri v2.
The OpenCode Desktop app, built with Electron.
## Development
From the repo root:
```bash
bun install
bun run --cwd packages/desktop tauri dev
```
This starts the Vite dev server on http://localhost:1420 and opens the native window.
If you only want the web dev server (no native shell):
```bash
bun run --cwd packages/desktop dev
bun dev
```
## Build
To create a production `dist/` and build the native app bundle:
Run the `build` script to build the app's JS assets, then `package` to
bundle the assets as an application. The resulting app will be in `dist/`.
```bash
bun run --cwd packages/desktop tauri build
bun run build && bun run package
```
## Prerequisites
Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
+1 -1
View File
@@ -37,7 +37,7 @@ export default defineConfig({
},
build: {
rollupOptions: {
input: { index: "src/main/index.ts" },
input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" },
},
externalizeDeps: { include: [nodePtyPkg] },
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.39",
"version": "1.14.41",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+31 -24
View File
@@ -1,14 +1,22 @@
import { execFileSync } from "node:child_process"
import { existsSync, readFileSync, readdirSync } from "node:fs"
import { execFile, execFileSync } from "node:child_process"
import { access, readFile, readdir } from "node:fs/promises"
import { dirname, extname, join } from "node:path"
import util from "node:util"
export function checkAppExists(appName: string): boolean {
const execFilePromise = util.promisify(execFile)
const exists = (path: string) =>
access(path)
.then(() => true)
.catch(() => false)
export function checkAppExists(appName: string) {
if (process.platform === "win32") return true
if (process.platform === "linux") return true
return checkMacosApp(appName)
}
export function resolveAppPath(appName: string): string | null {
export function resolveAppPath(appName: string) {
if (process.platform !== "win32") return appName
return resolveWindowsAppPath(appName)
}
@@ -32,26 +40,25 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
}
}
function checkMacosApp(appName: string) {
async function checkMacosApp(appName: string) {
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
const home = process.env.HOME
if (home) locations.push(`${home}/Applications/${appName}.app`)
if (locations.some((location) => existsSync(location))) return true
try {
execFileSync("which", [appName])
return true
} catch {
return false
for (const location of locations) {
if (await exists(location)) return true
}
return execFilePromise("which", [appName])
.then(() => true)
.catch(() => false)
}
function resolveWindowsAppPath(appName: string): string | null {
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
let output: string
try {
output = execFileSync("where", [appName]).toString()
output = execFilePromise("where", [appName]).toString()
} catch {
return null
}
@@ -66,8 +73,8 @@ function resolveWindowsAppPath(appName: string): string | null {
const exe = paths.find((path) => hasExt(path, "exe"))
if (exe) return exe
const resolveCmd = (path: string) => {
const content = readFileSync(path, "utf8")
const resolveCmd = async (path: string) => {
const content = await readFile(path, "utf8")
for (const token of content.split('"').map((value: string) => value.trim())) {
const lower = token.toLowerCase()
if (!lower.includes(".exe")) continue
@@ -85,10 +92,10 @@ function resolveWindowsAppPath(appName: string): string | null {
return join(current, part)
}, base)
if (existsSync(resolved)) return resolved
if (await exists(resolved)) return resolved
}
if (existsSync(token)) return token
if (await exists(token)) return token
}
return null
@@ -96,20 +103,20 @@ function resolveWindowsAppPath(appName: string): string | null {
for (const path of paths) {
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
const resolved = resolveCmd(path)
const resolved = await resolveCmd(path)
if (resolved) return resolved
}
if (!extname(path)) {
const cmd = `${path}.cmd`
if (existsSync(cmd)) {
const resolved = resolveCmd(cmd)
if (await exists(cmd)) {
const resolved = await resolveCmd(cmd)
if (resolved) return resolved
}
const bat = `${path}.bat`
if (existsSync(bat)) {
const resolved = resolveCmd(bat)
if (await exists(bat)) {
const resolved = await resolveCmd(bat)
if (resolved) return resolved
}
}
@@ -126,7 +133,7 @@ function resolveWindowsAppPath(appName: string): string | null {
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
for (const dir of dirs) {
try {
for (const entry of readdirSync(dir)) {
for (const entry of await readdir(dir)) {
const candidate = join(dir, entry)
if (!hasExt(candidate, "exe")) continue
const stem = entry.replace(/\.exe$/i, "")
+1
View File
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
+41 -38
View File
@@ -47,7 +47,15 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import {
getDefaultServerUrl,
getWslConfig,
preferAppEnv,
setDefaultServerUrl,
setWslConfig,
spawnLocalServer,
type SidecarListener,
} from "./server"
import {
createLoadingWindow,
createMainWindow,
@@ -55,15 +63,13 @@ import {
setBackgroundColor,
setDockIcon,
} from "./windows"
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
import { migrate } from "./migrate"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let server: Server.Listener | null = null
let server: SidecarListener | null = null
const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
@@ -107,6 +113,8 @@ function setupApp() {
return
}
preferAppEnv(app.getPath("userData"))
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
if (urls.length) {
@@ -123,17 +131,16 @@ function setupApp() {
})
app.on("before-quit", () => {
killSidecar()
void killSidecar()
})
app.on("will-quit", () => {
killSidecar()
void killSidecar()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
app.exit(0)
void killSidecar().finally(() => app.exit(0))
})
}
@@ -184,7 +191,6 @@ function setInitStep(step: InitStep) {
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
@@ -199,31 +205,26 @@ async function initialize() {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
if (needsMigration) {
const { Database, JsonMigration } = await import("virtual:opencode-server")
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
progress: (event: { current: number; total: number }) => {
const percent = Math.round(event.current / event.total) * 100
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
},
})
initEmitter.emit("sqlite", { type: "Done" })
sqliteDone?.resolve()
}
if (needsMigration) {
await sqliteDone?.promise
}
logger.log("spawning sidecar", { url })
const { listener, health } = await spawnLocalServer(hostname, port, password, () => {
ensureLoopbackNoProxy()
useEnvProxy()
})
const { listener, health } = await spawnLocalServer(
hostname,
port,
password,
() => {
ensureLoopbackNoProxy()
useEnvProxy()
},
{
needsMigration,
userDataPath: app.getPath("userData"),
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
onStdout: (message) => logger.log("sidecar stdout", { message }),
onStderr: (message) => logger.warn("sidecar stderr", { message }),
onExit: (code) => logger.warn("sidecar exited", { code }),
},
)
server = listener
serverReady.resolve({
url,
@@ -273,9 +274,10 @@ function wireMenu() {
},
reload: () => mainWindow?.reload(),
relaunch: () => {
killSidecar()
app.relaunch()
app.exit(0)
void killSidecar().finally(() => {
app.relaunch()
app.exit(0)
})
},
})
}
@@ -304,7 +306,7 @@ registerIpcHandlers({
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: async (appName) => checkAppExists(appName),
checkAppExists: (appName) => checkAppExists(appName),
wslPath: async (path, mode) => wslPath(path, mode),
resolveAppPath: async (appName) => resolveAppPath(appName),
loadingWindowComplete: () => loadingComplete.resolve(),
@@ -314,10 +316,11 @@ registerIpcHandlers({
setBackgroundColor: (color) => setBackgroundColor(color),
})
function killSidecar() {
async function killSidecar() {
if (!server) return
server.stop()
const current = server
server = null
await current.stop()
}
function ensureLoopbackNoProxy() {
@@ -440,7 +443,7 @@ async function installUpdate() {
logger.log("installing downloaded update", {
version: downloadedUpdateVersion,
})
killSidecar()
await killSidecar()
autoUpdater.quitAndInstall()
}
+1 -1
View File
@@ -19,7 +19,7 @@ const pickerFilters = (ext?: string[]) => {
}
type Deps = {
killSidecar: () => void
killSidecar: () => Promise<void> | void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
consumeInitialDeepLinks: () => Promise<string[]> | string[]
+5
View File
@@ -23,6 +23,11 @@ export function createMenu(deps: Deps) {
enabled: UPDATER_ENABLED,
click: () => deps.checkForUpdates(),
},
{
label: "Settings",
accelerator: "Cmd+,",
click: () => deps.trigger("settings.open"),
},
{
label: "Reload Webview",
click: () => deps.reload(),
+188 -29
View File
@@ -1,12 +1,37 @@
import { app } from "electron"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { app, utilityProcess } from "electron"
import type { Details } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
import { getStore } from "./store"
import type { SqliteMigrationProgress } from "../preload/types"
export type WslConfig = { enabled: boolean }
export type HealthCheck = { wait: Promise<void> }
type SidecarMessage =
| { type: "sqlite"; progress: SqliteMigrationProgress }
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
export type SidecarListener = { stop: () => Promise<void> }
const SIDECAR_SERVICE_NAME = "opencode server"
const SIDECAR_START_STALL_TIMEOUT = 60_000
const SIDECAR_STOP_TIMEOUT = 6_000
type SpawnLocalServerOptions = {
needsMigration: boolean
userDataPath: string
onSqliteProgress?: (progress: SqliteMigrationProgress) => void
onStdout?: (message: string) => void
onStderr?: (message: string) => void
onExit?: (code: number) => void
}
export function getDefaultServerUrl(): string | null {
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
@@ -30,49 +55,155 @@ export function setWslConfig(config: WslConfig) {
getStore().set(WSL_ENABLED_KEY, config.enabled)
}
export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) {
prepareServerEnv(password)
export function preferAppEnv(userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
Object.assign(
process.env,
mergeShellEnv(shell ? loadShellEnv(shell) : null, {
...process.env,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
}),
)
}
export async function spawnLocalServer(
hostname: string,
port: number,
password: string,
configureEnv: () => void,
options: SpawnLocalServerOptions,
) {
configureEnv?.()
const { Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
hostname,
username: "opencode",
password,
cors: ["oc://renderer"],
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
const child = utilityProcess.fork(sidecar, [], {
cwd: process.cwd(),
env: createSidecarEnv(),
serviceName: SIDECAR_SERVICE_NAME,
stdio: "pipe",
})
let exited = false
const exit = defer<number>()
const onProcessGone = (_event: unknown, details: Details) => {
if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
}
app.on("child-process-gone", onProcessGone)
child.once("exit", (code) => {
exited = true
app.off("child-process-gone", onProcessGone)
options.onExit?.(code)
exit.resolve(code)
})
child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
await new Promise<void>((resolve, reject) => {
let done = false
let timeout: NodeJS.Timeout
const fail = (error: Error) => {
if (done) return
done = true
cleanup()
reject(error)
}
const refreshTimeout = () => {
clearTimeout(timeout)
timeout = setTimeout(() => {
fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
}, SIDECAR_START_STALL_TIMEOUT)
}
const onMessage = (message: SidecarMessage) => {
if (message.type === "sqlite") {
refreshTimeout()
options.onSqliteProgress?.(message.progress)
return
}
if (message.type === "ready") {
if (done) return
done = true
cleanup()
resolve()
return
}
if (message.type === "error") {
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
}
}
const onExit = (code: number) => {
fail(new Error(`Sidecar exited before ready with code ${code}`))
}
const cleanup = () => {
clearTimeout(timeout)
child.off("message", onMessage)
child.off("exit", onExit)
}
child.on("message", onMessage)
child.on("exit", onExit)
refreshTimeout()
child.postMessage({
type: "start",
hostname,
port,
password,
userDataPath: options.userDataPath,
needsMigration: options.needsMigration,
})
}).catch((error) => {
if (!exited) child.kill()
throw error
})
const wait = (async () => {
const url = `http://${hostname}:${port}`
let healthy = false
const gone = exit.promise.then((code) => {
if (healthy) return
throw new Error(`Sidecar exited before health check passed with code ${code}`)
})
const ready = async () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) return
if (await checkHealth(url, password)) {
healthy = true
return
}
}
}
await ready()
await Promise.race([ready(), gone])
})()
return { listener, health: { wait } }
}
let stopping: Promise<void> | undefined
function prepareServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
const env = {
...process.env,
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? app.getPath("userData"),
return {
listener: {
stop: () => {
if (stopping) return stopping
if (exited) return Promise.resolve()
child.postMessage({ type: "stop" })
stopping = Promise.race([
exit.promise.then(() => undefined),
delay(SIDECAR_STOP_TIMEOUT).then(() => {
if (!exited) child.kill()
}),
])
return stopping
},
},
health: { wait },
}
Object.assign(process.env, env)
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
@@ -100,3 +231,31 @@ export async function checkHealth(url: string, password?: string | null): Promis
return false
}
}
function createSidecarEnv(): Record<string, string> {
const env = Object.fromEntries(
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
)
delete env.DEBUG
if (process.platform === "linux") delete env.LD_PRELOAD
return env
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
function serializeError(error: unknown) {
if (error instanceof Error) return { message: error.message, stack: error.stack }
return { message: String(error) }
}
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
+178
View File
@@ -0,0 +1,178 @@
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import * as http from "node:http"
import * as tls from "node:tls"
type NodeHttpWithEnvProxy = typeof http & {
setGlobalProxyFromEnv: () => void
}
type NodeTlsWithSystemCertificates = typeof tls & {
getCACertificates: (type: "default" | "system") => string[]
setDefaultCACertificates: (certificates: string[]) => void
}
type StartCommand = {
type: "start"
hostname: string
port: number
password: string
userDataPath: string
needsMigration: boolean
}
type StopCommand = { type: "stop" }
type SidecarCommand = StartCommand | StopCommand
type SidecarMessage =
| { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } }
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
type ParentPort = {
postMessage(message: SidecarMessage): void
on(event: "message", listener: (event: { data: unknown }) => void): void
}
type Listener = {
stop(close?: boolean): void | Promise<void>
}
const parentPort = getParentPort()
let listener: Listener | undefined
parentPort.on("message", (event) => {
const command = parseCommand(event.data)
if (!command) return
if (command.type === "stop") {
void stop()
return
}
void start(command)
})
async function start(command: StartCommand) {
try {
prepareSidecarEnv(command.password, command.userDataPath)
ensureLoopbackNoProxy()
useSystemCertificates()
useEnvProxy()
const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
if (command.needsMigration) {
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
progress: (event: { current: number; total: number }) => {
parentPort.postMessage({
type: "sqlite",
progress: {
type: "InProgress",
value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100),
},
})
},
})
parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } })
}
listener = await Server.listen({
port: command.port,
hostname: command.hostname,
username: "opencode",
password: command.password,
cors: ["oc://renderer"],
})
parentPort.postMessage({ type: "ready" })
} catch (error) {
parentPort.postMessage({ type: "error", error: serializeError(error) })
setImmediate(() => process.exit(1))
}
}
async function stop() {
try {
await listener?.stop()
} finally {
listener = undefined
parentPort.postMessage({ type: "stopped" })
setImmediate(() => process.exit(0))
}
}
function prepareSidecarEnv(password: string, userDataPath: string) {
Object.assign(process.env, {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
}
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {
const items = (process.env[key] ?? "")
.split(",")
.map((value: string) => value.trim())
.filter((value: string) => Boolean(value))
for (const host of loopback) {
if (items.some((value: string) => value.toLowerCase() === host)) continue
items.push(host)
}
process.env[key] = items.join(",")
}
upsert("NO_PROXY")
upsert("no_proxy")
}
function useSystemCertificates() {
try {
const nodeTls = tls as NodeTlsWithSystemCertificates
nodeTls.setDefaultCACertificates([
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
])
} catch (error) {
console.warn("failed to load system certificates", error)
}
}
function useEnvProxy() {
try {
;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
} catch (error) {
console.warn("failed to load proxy environment", error)
}
}
function parseCommand(value: unknown): SidecarCommand | undefined {
if (!value || typeof value !== "object") return
const command = value as Partial<StartCommand | StopCommand>
if (command.type === "stop") return { type: "stop" }
if (command.type !== "start") return
if (typeof command.hostname !== "string") return
if (typeof command.port !== "number") return
if (typeof command.password !== "string") return
if (typeof command.userDataPath !== "string") return
if (typeof command.needsMigration !== "boolean") return
return {
type: "start",
hostname: command.hostname,
port: command.port,
password: command.password,
userDataPath: command.userDataPath,
needsMigration: command.needsMigration,
}
}
function serializeError(error: unknown) {
if (error instanceof Error) return { message: error.message, stack: error.stack }
return { message: String(error) }
}
function getParentPort() {
const port = process.parentPort as ParentPort | undefined
if (!port) throw new Error("Sidecar parent port unavailable")
return port
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.39",
"version": "1.14.41",
"private": true,
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -91,8 +91,8 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
"type": "sst.sst.Secret"
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.39"
version = "1.14.41"
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.14.39/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/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.14.39/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/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.14.39/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.39",
"version": "1.14.41",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+2 -2
View File
@@ -91,8 +91,8 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
"type": "sst.sst.Secret"
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
@@ -2,7 +2,7 @@
"version": "7",
"dialect": "sqlite",
"id": "27114226-085b-421a-9a40-29b88747e29a",
"prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"],
"prevIds": ["2ec89846-dcf1-4977-ab5e-244ddc9e3d67"],
"ddl": [
{
"name": "account_state",
@@ -37,7 +37,7 @@
"entityType": "tables"
},
{
"name": "session_entry",
"name": "session_message",
"entityType": "tables"
},
{
@@ -598,7 +598,7 @@
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_entry"
"table": "session_message"
},
{
"type": "text",
@@ -608,7 +608,7 @@
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_entry"
"table": "session_message"
},
{
"type": "text",
@@ -618,7 +618,7 @@
"generated": null,
"name": "type",
"entityType": "columns",
"table": "session_entry"
"table": "session_message"
},
{
"type": "integer",
@@ -628,7 +628,7 @@
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_entry"
"table": "session_message"
},
{
"type": "integer",
@@ -638,7 +638,7 @@
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_entry"
"table": "session_message"
},
{
"type": "text",
@@ -648,7 +648,7 @@
"generated": null,
"name": "data",
"entityType": "columns",
"table": "session_entry"
"table": "session_message"
},
{
"type": "text",
@@ -810,6 +810,26 @@
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "model",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
@@ -1122,9 +1142,9 @@
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_entry_session_id_session_id_fk",
"name": "fk_session_message_session_id_session_id_fk",
"entityType": "fks",
"table": "session_entry"
"table": "session_message"
},
{
"columns": ["project_id"],
@@ -1236,8 +1256,8 @@
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_entry_pk",
"table": "session_entry",
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
@@ -1332,9 +1352,9 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_entry_session_idx",
"name": "session_message_session_idx",
"entityType": "indexes",
"table": "session_entry"
"table": "session_message"
},
{
"columns": [
@@ -1350,9 +1370,9 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_entry_session_type_idx",
"name": "session_message_session_type_idx",
"entityType": "indexes",
"table": "session_entry"
"table": "session_message"
},
{
"columns": [
@@ -1364,9 +1384,9 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_entry_time_created_idx",
"name": "session_message_time_created_idx",
"entityType": "indexes",
"table": "session_entry"
"table": "session_message"
},
{
"columns": [
@@ -0,0 +1 @@
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.39",
"version": "1.14.41",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -80,7 +80,7 @@
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/anthropic": "3.0.71",
+185 -70
View File
@@ -5,6 +5,8 @@ import {
type AuthenticateRequest,
type AuthMethod,
type CancelNotification,
type CloseSessionRequest,
type CloseSessionResponse,
type ForkSessionRequest,
type ForkSessionResponse,
type InitializeRequest,
@@ -565,6 +567,7 @@ export class Agent implements ACPAgent {
image: true,
},
sessionCapabilities: {
close: {},
fork: {},
list: {},
resume: {},
@@ -627,6 +630,9 @@ export class Agent implements ACPAgent {
// Store ACP session state
await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
const messages = await this.loadSessionMessages(directory, sessionId)
this.restoreSessionStateFromMessages(sessionId, messages)
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
const result = await this.loadSessionMode({
@@ -635,39 +641,6 @@ export class Agent implements ACPAgent {
sessionId,
})
// Replay session history
const messages = await this.sdk.session
.messages(
{
sessionID: sessionId,
directory,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((err) => {
log.error("unexpected error when fetching message", { error: err })
return undefined
})
const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
if (lastUser?.role === "user") {
result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
this.sessionManager.setModel(sessionId, {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
})
if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) {
result.modes.currentModeId = lastUser.agent
this.sessionManager.setMode(sessionId, lastUser.agent)
}
result.configOptions = buildConfigOptions({
currentModelId: result.models.currentModelId,
availableModels: result.models.availableModels,
modes: result.modes,
})
}
for (const msg of messages ?? []) {
log.debug("replay message", msg)
await this.processMessage(msg)
@@ -756,6 +729,9 @@ export class Agent implements ACPAgent {
const sessionId = forked.id
await this.sessionManager.load(sessionId, directory, mcpServers, model)
const messages = await this.loadSessionMessages(directory, sessionId)
this.restoreSessionStateFromMessages(sessionId, messages)
log.info("fork_session", { sessionId, mcpServers: mcpServers.length })
const mode = await this.loadSessionMode({
@@ -764,20 +740,6 @@ export class Agent implements ACPAgent {
sessionId,
})
const messages = await this.sdk.session
.messages(
{
sessionID: sessionId,
directory,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((err) => {
log.error("unexpected error when fetching message", { error: err })
return undefined
})
for (const msg of messages ?? []) {
log.debug("replay message", msg)
await this.processMessage(msg)
@@ -797,7 +759,7 @@ export class Agent implements ACPAgent {
}
}
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
const directory = params.cwd
const sessionId = params.sessionId
const mcpServers = params.mcpServers ?? []
@@ -806,6 +768,9 @@ export class Agent implements ACPAgent {
const model = await defaultModel(this.config, directory)
await this.sessionManager.load(sessionId, directory, mcpServers, model)
const messages = await this.loadSessionMessages(directory, sessionId, 20)
this.restoreSessionStateFromMessages(sessionId, messages)
log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
const result = await this.loadSessionMode({
@@ -828,6 +793,27 @@ export class Agent implements ACPAgent {
}
}
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
const session = this.sessionManager.remove(params.sessionId)
if (!session) return {}
await this.sdk.session
.abort(
{
sessionID: params.sessionId,
directory: session.cwd,
},
{ throwOnError: true },
)
.catch((error) => {
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
})
this.permissionQueues.delete(params.sessionId)
log.info("close_session", { sessionId: params.sessionId })
return {}
}
private async processMessage(message: SessionMessageResponse) {
log.debug("process message", message)
if (message.info.role !== "assistant" && message.info.role !== "user") return
@@ -1159,23 +1145,26 @@ export class Agent implements ACPAgent {
sessionId: string,
): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
const availableModes = await this.loadAvailableModes(directory)
const currentModeId =
this.sessionManager.get(sessionId).modeId ||
(await (async () => {
if (!availableModes.length) return undefined
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
})())
const storedModeId = this.sessionManager.get(sessionId).modeId
if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) {
return { availableModes, currentModeId: storedModeId }
}
const currentModeId = await (async () => {
if (!availableModes.length) return undefined
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
})()
return { availableModes, currentModeId }
}
private async loadSessionMode(params: LoadSessionRequest) {
const directory = params.cwd
const model = await defaultModel(this.config, directory)
const sessionId = params.sessionId
const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory))
const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
const entries = sortProvidersByName(providers)
@@ -1184,7 +1173,7 @@ export class Agent implements ACPAgent {
if (currentVariant && !availableVariants.includes(currentVariant)) {
this.sessionManager.setVariant(sessionId, undefined)
}
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const availableModels = buildAvailableModels(entries)
const modeState = await this.resolveModeState(directory, sessionId)
const currentModeId = modeState.currentModeId
const modes = currentModeId
@@ -1267,13 +1256,15 @@ export class Agent implements ACPAgent {
return {
sessionId,
models: {
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
availableModels,
},
modes,
configOptions: buildConfigOptions({
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
availableModels,
currentVariant,
availableVariants,
modes,
}),
_meta: buildVariantMeta({
@@ -1296,6 +1287,24 @@ export class Agent implements ACPAgent {
const entries = sortProvidersByName(providers)
const availableVariants = modelVariantsFromProviders(entries, selection.model)
const modeState = await this.resolveModeState(session.cwd, session.id)
const modes = modeState.currentModeId
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
: undefined
await this.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "config_option_update",
configOptions: buildConfigOptions({
currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false),
availableModels: buildAvailableModels(entries),
currentVariant: selection.variant,
availableVariants,
modes,
}),
},
})
return {
_meta: buildVariantMeta({
@@ -1327,6 +1336,14 @@ export class Agent implements ACPAgent {
const selection = parseModelSelection(params.value, providers)
this.sessionManager.setModel(session.id, selection.model)
this.sessionManager.setVariant(session.id, selection.variant)
} else if (params.configId === "effort") {
if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string")
const current = session.model ?? (await defaultModel(this.config, session.cwd))
const availableVariants = modelVariantsFromProviders(entries, current)
if (!availableVariants.includes(params.value)) {
throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` }))
}
this.sessionManager.setVariant(session.id, params.value)
} else if (params.configId === "mode") {
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
const availableModes = await this.loadAvailableModes(session.cwd)
@@ -1341,15 +1358,21 @@ export class Agent implements ACPAgent {
const updatedSession = this.sessionManager.get(session.id)
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
const availableVariants = modelVariantsFromProviders(entries, model)
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false)
const availableModels = buildAvailableModels(entries)
const modeState = await this.resolveModeState(session.cwd, session.id)
const modes = modeState.currentModeId
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
: undefined
return {
configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
configOptions: buildConfigOptions({
currentModelId,
availableModels,
currentVariant: updatedSession.variant,
availableVariants,
modes,
}),
}
}
@@ -1546,6 +1569,37 @@ export class Agent implements ACPAgent {
{ throwOnError: true },
)
}
private async loadSessionMessages(directory: string, sessionId: string, limit?: number) {
return this.sdk.session
.messages(
{
sessionID: sessionId,
directory,
limit,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((error) => {
log.error("unexpected error when fetching message", { error })
return undefined
})
}
private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) {
const lastUser = messages?.findLast((message) => message.info.role === "user")?.info
if (lastUser?.role !== "user") return
this.sessionManager.setModel(sessionId, {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
})
this.sessionManager.setVariant(sessionId, lastUser.model.variant)
if (lastUser.agent) {
this.sessionManager.setMode(sessionId, lastUser.agent)
}
}
}
function toToolKind(toolName: string): ToolKind {
@@ -1629,11 +1683,11 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
if (specified && !providers.length) return specified
const lastUsed = await lastUsedModel(sdk, directory, providers)
if (lastUsed) return lastUsed
const opencodeProvider = providers.find((p) => p.id === "opencode")
if (opencodeProvider) {
if (opencodeProvider.models["big-pickle"]) {
return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
}
const [best] = Provider.sort(Object.values(opencodeProvider.models))
if (best) {
return {
@@ -1653,8 +1707,38 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
}
if (specified) return specified
throw new Error("No models available")
}
return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
async function lastUsedModel(
sdk: OpencodeClient,
directory: string,
providers: Array<{ id: string; models: Record<string, unknown> }>,
): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> {
const session = await sdk.session
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
.then((x) => x.data?.[0])
.catch((error) => {
log.error("failed to list sessions for default model", { error })
return undefined
})
if (!session) return
const lastUser = await sdk.session
.messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true })
.then((x) => x.data?.findLast((message) => message.info.role === "user")?.info)
.catch((error) => {
log.error("failed to load session messages for default model", { error, sessionID: session.id })
return undefined
})
if (lastUser?.role !== "user") return
const provider = providers.find((entry) => entry.id === lastUser.model.providerID)
if (!provider?.models[lastUser.model.modelID]) return
return {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
}
}
function parseUri(
@@ -1757,8 +1841,14 @@ function formatModelIdWithVariant(
includeVariant: boolean,
) {
const base = `${model.providerID}/${model.modelID}`
if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
return `${base}/${variant}`
if (!includeVariant || availableVariants.length === 0) return base
const selectedVariant =
variant && availableVariants.includes(variant)
? variant
: availableVariants.includes(DEFAULT_VARIANT_VALUE)
? DEFAULT_VARIANT_VALUE
: availableVariants[0]
return `${base}/${selectedVariant}`
}
function buildVariantMeta(input: {
@@ -1810,6 +1900,8 @@ function parseModelSelection(
function buildConfigOptions(input: {
currentModelId: string
availableModels: ModelOption[]
currentVariant?: string
availableVariants?: string[]
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
}): SessionConfigOption[] {
const options: SessionConfigOption[] = [
@@ -1822,6 +1914,22 @@ function buildConfigOptions(input: {
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
},
]
if (input.availableVariants?.length) {
options.push({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue:
input.currentVariant && input.availableVariants.includes(input.currentVariant)
? input.currentVariant
: input.availableVariants.includes(DEFAULT_VARIANT_VALUE)
? DEFAULT_VARIANT_VALUE
: input.availableVariants[0],
options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
})
}
if (input.modes) {
options.push({
id: "mode",
@@ -1839,4 +1947,11 @@ function buildConfigOptions(input: {
return options
}
function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
export * as ACP from "./agent"
+6
View File
@@ -113,4 +113,10 @@ export class ACPSessionManager {
this.sessions.set(sessionId, session)
return session
}
remove(sessionId: string): ACPSessionState | undefined {
const session = this.sessions.get(sessionId)
this.sessions.delete(sessionId)
return session
}
}
@@ -25,6 +25,60 @@ const PROVIDER_PRIORITY: Record<string, number> = {
google: 5,
}
const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__"
const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
type ProviderOptionBase = {
title: string
value: string
description?: string
category: string
}
type ProviderOption =
| (ProviderOptionBase & {
type: "provider"
providerID: string
})
| (ProviderOptionBase & {
type: "custom"
})
export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
return [
...pipe(
list,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
type: "provider" as const,
title: provider.name,
value: provider.id,
providerID: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers",
})),
),
{
type: "custom",
title: "Other",
value: CUSTOM_PROVIDER_OPTION_VALUE,
description: "Custom provider",
category: "Providers",
},
]
}
export function normalizeCustomProviderID(value: string) {
const providerID = value.trim().replace(/^@ai-sdk\//, "")
if (!CUSTOM_PROVIDER_ID.test(providerID)) return
return providerID
}
export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
@@ -32,30 +86,62 @@ export function createDialogProviderOptions() {
const toast = useToast()
const { theme } = useTheme()
const onboarded = useConnected()
async function promptCustomProviderID(): Promise<string | undefined> {
const value = await DialogPrompt.show(dialog, "Other", {
placeholder: "Provider id",
description: () => (
<text fg={theme.textMuted}>
This only stores a credential. Configure the provider in opencode.json to use it.
</text>
),
})
if (value === null) return
const providerID = normalizeCustomProviderID(value)
if (providerID) return providerID
toast.show({
variant: "error",
message:
"Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores",
})
return promptCustomProviderID()
}
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
providerOptions(sync.data.provider_next.all),
map((provider) => {
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id)
const connected = sync.data.provider_next.connected.includes(provider.id)
if (provider.type === "custom") {
return {
title: provider.title,
value: provider.value,
description: provider.description,
category: provider.category,
async onSelect() {
const providerID = await promptCustomProviderID()
if (!providerID) return
return dialog.replace(() => <ApiMethod providerID={providerID} title="API key" custom />)
},
}
}
const providerID = provider.providerID
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
const connected = sync.data.provider_next.connected.includes(providerID)
return {
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
title: provider.title,
value: provider.value,
description: provider.description,
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
category: provider.category,
gutter: connected && onboarded() ? () => <text fg={theme.success}></text> : undefined,
async onSelect() {
if (consoleManaged) return
const methods = sync.data.provider_auth[provider.id] ?? [
const methods = sync.data.provider_auth[providerID] ?? [
{
type: "api",
label: "API key",
@@ -93,7 +179,7 @@ export function createDialogProviderOptions() {
}
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
providerID,
method: index,
inputs,
})
@@ -107,22 +193,12 @@ export function createDialogProviderOptions() {
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
<CodeMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
<AutoMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
))
}
}
@@ -134,7 +210,7 @@ export function createDialogProviderOptions() {
metadata = value
}
return dialog.replace(() => (
<ApiMethod providerID={provider.id} title={method.label} metadata={metadata} />
<ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
))
}
},
@@ -256,11 +332,13 @@ interface ApiMethodProps {
providerID: string
title: string
metadata?: Record<string, string>
custom?: boolean
}
function ApiMethod(props: ApiMethodProps) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const { theme } = useTheme()
return (
@@ -305,6 +383,14 @@ function ApiMethod(props: ApiMethodProps) {
})
await sdk.client.instance.dispose()
await sync.bootstrap()
if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) {
toast.show({
variant: "info",
message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`,
})
dialog.clear()
return
}
dialog.replace(() => <DialogModel providerID={props.providerID} />)
}}
/>
@@ -70,8 +70,10 @@ export function DialogSessionList() {
sync,
project,
toast,
sourceWorkspaceID: session.workspaceID,
workspaceID,
sessionID: session.id,
copyChanges: false,
done: list,
})
}
@@ -105,6 +107,7 @@ export function DialogSessionList() {
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warp(selection)
@@ -3,10 +3,13 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { useRoute } from "@tui/context/route"
import { createMemo, createSignal, onMount } from "solid-js"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
type Adapter = {
type: string
@@ -33,19 +36,14 @@ export type WorkspaceSelection =
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(input: {
sessions: readonly { workspaceID?: string; time: { updated: number } }[]
get: (workspaceID: string) => WorkspaceInfo | undefined
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
workspaces: readonly WorkspaceInfo[]
status: (workspaceID: string) => string | undefined
limit?: number
omitWorkspaceID?: string
}) {
const workspaces = input.sessions
.toSorted((a, b) => b.time.updated - a.time.updated)
.flatMap((session) => {
const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined
return workspace && input.status(workspace.id) === "connected" ? [workspace] : []
})
.filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index)
const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
const recent = workspaces.slice(0, input.limit ?? 3)
return { recent, hasMore: recent.length < workspaces.length }
@@ -78,10 +76,13 @@ export async function openWorkspaceSelect(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
input.dialog.clear()
await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
await input.project.workspace.sync().catch(() => undefined)
const adapters = await loadWorkspaceAdapters(input)
if (!adapters) return
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
@@ -93,17 +94,29 @@ export async function warpWorkspaceSession(input: {
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
sourceWorkspaceID?: string
workspaceID: string | null
sessionID: string
copyChanges: boolean
done?: () => void
}): Promise<boolean> {
const result = await input.sdk.client.experimental.workspace
.warp({
id: input.workspaceID,
sessionID: input.sessionID,
copyChanges: input.copyChanges,
})
.catch(() => undefined)
if (!result?.data) {
if (result?.error?.name === "VcsApplyError") {
await DialogAlert.show(
input.dialog,
"Unable to Warp Session",
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
)
return false
}
input.toast.show({
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
@@ -143,16 +156,31 @@ export async function warpWorkspaceSession(input: {
return true
}
export async function confirmWorkspaceFileChanges(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sourceWorkspaceID?: string
}) {
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
const fileChangeChoice = status?.data?.length
? await DialogWorkspaceFileChanges.show(input.dialog, status.data)
: "no"
if (!fileChangeChoice) return
return fileChangeChoice === "yes"
}
export function DialogWorkspaceSelect(props: {
adapters?: Adapter[]
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const dialog = useDialog()
const project = useProject()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
onMount(() => {
dialog.setSize("medium")
@@ -168,9 +196,9 @@ export function DialogWorkspaceSelect(props: {
const list = adapters()
if (!list) return []
const { recent, hasMore } = recentConnectedWorkspaces({
sessions: sync.data.session,
get: project.workspace.get,
workspaces: project.workspace.list(),
status: project.workspace.status,
omitWorkspaceID: omittedWorkspaceID(),
})
return [
...list.map((adapter) => ({
@@ -231,19 +259,25 @@ export function DialogWorkspaceSelect(props: {
return
}
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
dialog.replace(() => (
<DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />
))
}}
/>
)
}
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
function DialogExistingWorkspaceSelect(props: {
omitWorkspaceID?: string
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const project = useProject()
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
project.workspace
.list()
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
@@ -0,0 +1,138 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { Locale } from "@/util/locale"
import { useTheme } from "../context/theme"
import { useTuiConfig } from "../context/tui-config"
import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll"
const options = ["no", "yes"] as const
export type WorkspaceFileChangesChoice = (typeof options)[number]
function statusLabel(status: VcsFileStatus["status"]) {
if (status === "added") return "A"
if (status === "deleted") return "D"
return "M"
}
function changeCountWidth(file: VcsFileStatus) {
// The "plus 2" is for spaces
return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2
}
export function DialogWorkspaceFileChanges(props: {
files: VcsFileStatus[]
onSelect: (choice: WorkspaceFileChangesChoice) => void
}) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
const height = createMemo(() => Math.min(props.files.length, 8))
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
function confirm() {
props.onSelect(store.active)
dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
const index = options.indexOf(store.active)
setStore("active", options[Math.max(index - 1, 0)])
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
const index = options.indexOf(store.active)
setStore("active", options[Math.min(index + 1, options.length - 1)])
}
})
return (
<box gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
File Changes Found
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<scrollbox
height={height()}
backgroundColor={theme.backgroundElement}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
<For each={props.files}>
{(item) => (
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<box flexDirection="row" minWidth={0} flexShrink={1}>
<box width={2} flexShrink={0}>
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
</box>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateLeft(item.file, fileNameWidth())}
</text>
</box>
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
<text>
{" "}
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
</text>
</box>
</box>
)}
</For>
</scrollbox>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
Do you want to apply these changes after warping?
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
props.onSelect(item)
dialog.clear()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}
DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => {
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
dialog.replace(
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} />,
() => resolve(undefined),
)
})
}
@@ -42,7 +42,12 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
import {
confirmWorkspaceFileChanges,
openWorkspaceSelect,
warpWorkspaceSession,
type WorkspaceSelection,
} from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -230,6 +235,9 @@ export function Prompt(props: PromptProps) {
if (selection.type === "new") void createWorkspace(selection)
return
}
const sourceWorkspaceID = project.workspace.current()
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
if (copyChanges === undefined) return
selectWorkspace(selection)
dialog.clear()
@@ -247,8 +255,10 @@ export function Prompt(props: PromptProps) {
sync,
project,
toast,
sourceWorkspaceID,
workspaceID: workspace.id,
sessionID: props.sessionID,
copyChanges,
})
if (warped) showWarpNotice(workspace.name)
}
@@ -590,6 +600,7 @@ export function Prompt(props: PromptProps) {
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warpSession(selection)
@@ -844,6 +855,7 @@ export function Prompt(props: PromptProps) {
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warpSession(selection)
@@ -18,22 +18,18 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter
throw new Error(`Unknown workspace adapter: ${type}`)
}
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adapter]) => {
return {
type,
name: adapter.name,
description: adapter.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] {
return registeredAdapters(projectID).map(([type, adapter]) => ({
type,
name: adapter.name,
description: adapter.description,
}))
return [...builtin, ...custom]
}
export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] {
const adapters = new Map(Object.entries(BUILTIN))
for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter)
return [...adapters.entries()]
}
// Plugins can be loaded per-project so we need to scope them. If you
@@ -3,14 +3,18 @@ import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name,
branch: Schema.String,
branch: Schema.optional(Schema.NullOr(Schema.String)),
directory: Schema.String,
})
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() {
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([
import("@/effect/app-runtime"),
import("@/project/instance"),
import("@/worktree"),
])
return { AppRuntime, Instance, Worktree }
}
export const WorktreeAdapter: WorkspaceAdapter = {
@@ -34,11 +38,22 @@ export const WorktreeAdapter: WorkspaceAdapter = {
svc.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
branch: config.branch ?? config.name,
}),
),
)
},
async list() {
const { AppRuntime, Instance, Worktree } = await loadWorktree()
return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({
type: "worktree",
name: info.name,
branch: info.branch ?? null,
directory: info.directory,
extra: null,
projectID: Instance.project.id,
}))
},
async remove(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
+7 -1
View File
@@ -1,4 +1,4 @@
import { Schema } from "effect"
import { Schema, Struct } from "effect"
import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema"
import { zod } from "@/util/effect-zod"
@@ -17,6 +17,11 @@ export const WorkspaceInfo = Schema.Struct({
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"]))
.annotate({ identifier: "WorkspaceListedInfo" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceListedInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceListedInfo>>
export const WorkspaceAdapterEntry = Schema.Struct({
type: Schema.String,
name: Schema.String,
@@ -40,6 +45,7 @@ export type WorkspaceAdapter = {
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
}
@@ -1,4 +1,4 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import type { WorkspaceID } from "./schema"
@@ -14,4 +14,7 @@ export const WorkspaceTable = sqliteTable("workspace", {
.$type<ProjectID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
.notNull()
.$default(() => Date.now()),
})
@@ -17,8 +17,8 @@ import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdapter } from "./adapters"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { getAdapter, registeredAdapters } from "./adapters"
import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { Session } from "@/session/session"
import { SessionPrompt } from "@/session/prompt"
@@ -31,9 +31,17 @@ import { WorkspaceContext } from "./workspace-context"
import { EffectBridge } from "@/effect/bridge"
import { withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod"
import { Vcs } from "@/project/vcs"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
export const Info = WorkspaceInfoSchema
export type Info = WorkspaceInfo
export const Info = Schema.Struct({
...WorkspaceInfoSchema.fields,
timeUsed: Schema.Number,
})
.annotate({ identifier: "Workspace" })
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
export type Info = WorkspaceInfo & { timeUsed: number }
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
@@ -66,6 +74,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
directory: row.directory,
extra: row.extra,
projectID: row.project_id,
timeUsed: row.time_used,
}
}
@@ -86,6 +95,7 @@ export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const SessionWarpInput = Schema.Struct({
workspaceID: Schema.NullOr(WorkspaceID),
sessionID: SessionID,
copyChanges: Schema.optional(Schema.Boolean),
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
@@ -137,6 +147,7 @@ type SessionWarpError =
| WorkspaceNotFoundError
| SessionEventsNotFoundError
| SessionWarpHttpError
| Vcs.PatchApplyError
| HttpClientError.HttpClientError
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
@@ -145,6 +156,7 @@ export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly syncList: (project: Project.Info) => Effect.Effect<void>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly status: () => Effect.Effect<ConnectionStatus[]>
@@ -167,6 +179,7 @@ export const layer = Layer.effect(
const prompt = yield* SessionPrompt.Service
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const vcs = yield* Vcs.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
@@ -255,6 +268,66 @@ export const layer = Layer.effect(
)
})
const runInWorkspace = <A, E, R>(input: {
workspaceID?: WorkspaceID
local: () => Effect.Effect<A, E, R>
remote: (input: {
workspace: Info
target: Extract<Target, { type: "remote" }>
}) => HttpClientRequest.HttpClientRequest
fallback: A
response?: "json" | "text"
}) =>
Effect.gen(function* () {
if (!input.workspaceID) return yield* input.local()
const workspace = yield* get(input.workspaceID)
if (!workspace) return input.fallback
const adapter = getAdapter(workspace.projectID, workspace.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
if (target.type === "local") {
const store = yield* InstanceStore.Service
return yield* store.provide({ directory: target.directory }, input.local())
}
const response = yield* http.execute(input.remote({ workspace, target })).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.warn("workspace target request failed", {
workspaceID: workspace.id,
error: errorData(error),
})
}),
),
)
if (!response) return input.fallback
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
log.warn("workspace target request failed", {
workspaceID: workspace.id,
status: response.status,
body,
})
return input.fallback
}
const body = input.response === "text" ? response.text : response.json
return yield* body.pipe(
Effect.map((result) => result as A),
Effect.catch((error) =>
Effect.sync(() => {
log.warn("workspace target response decode failed", {
workspaceID: workspace.id,
error: errorData(error),
})
return input.fallback
}),
),
)
})
const syncHistory = Effect.fn("Workspace.syncHistory")(function* (
space: Info,
url: URL | string,
@@ -417,7 +490,19 @@ export const layer = Layer.effect(
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.warn("workspace target failed", {
workspaceID: space.id,
error: errorData(error),
})
return null
}),
),
)
if (!target) return
if (target.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
@@ -457,7 +542,13 @@ export const layer = Layer.effect(
const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() =>
adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
adapter.configure({
...input,
id,
name: Slug.create(),
directory: null,
extra: input.extra ?? null,
}),
)
const info: Info = {
@@ -468,6 +559,7 @@ export const layer = Layer.effect(
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
timeUsed: Date.now(),
}
yield* db((db) => {
@@ -480,6 +572,7 @@ export const layer = Layer.effect(
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
})
@@ -557,6 +650,36 @@ export const layer = Layer.effect(
}
}
const sourcePatch =
input.copyChanges && current?.workspaceID
? yield* runInWorkspace({
workspaceID: current?.workspaceID ?? undefined,
local: () => vcs.diffRaw(),
remote: ({ target }) =>
HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), {
headers: new Headers(target.headers),
}),
fallback: "",
response: "text",
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
: ""
if (sourcePatch) {
// Attempt to apply the file changes to the new workspace.
// We intentionally do first so if it fails we don't warp
// the session.
yield* runInWorkspace({
workspaceID: input.workspaceID ?? undefined,
local: () => vcs.apply({ patch: sourcePatch }),
remote: ({ target }) =>
HttpClientRequest.post(route(target.url, "/vcs/apply"), {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({ patch: sourcePatch }),
}),
fallback: { applied: false },
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
}
if (input.workspaceID === null) {
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, {
@@ -732,6 +855,63 @@ export const layer = Layer.effect(
)
})
const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) {
const names = new Set((yield* list(project)).map((workspace) => workspace.name))
const discovered = yield* Effect.forEach(
registeredAdapters(project.id),
([type, adapter]) =>
adapter.list
? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error })
return []
}),
),
)
: Effect.succeed([]),
{ concurrency: "unbounded" },
).pipe(Effect.map((items) => items.flat()))
yield* Effect.forEach(
discovered,
(item) =>
Effect.gen(function* () {
if (names.has(item.name)) return
names.add(item.name)
const info: Info = {
id: WorkspaceID.ascending(),
type: item.type,
branch: item.branch,
name: item.name,
directory: item.directory,
extra: item.extra,
projectID: item.projectID,
timeUsed: Date.now(),
}
yield* db((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
})
yield* startSync(info)
}),
{ concurrency: 1 },
)
})
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
@@ -820,13 +1000,10 @@ export const layer = Layer.effect(
})
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
// This session table join makes this query only return
// workspaces that have sessions
const rows = yield* db((db) =>
db
.selectDistinct({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
@@ -851,6 +1028,7 @@ export const layer = Layer.effect(
create,
sessionWarp,
list,
syncList,
get,
remove,
status,
@@ -866,6 +1044,8 @@ export const defaultLayer = layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
+3
View File
@@ -91,6 +91,9 @@ export const layer = Layer.effect(
cwd: dir,
env: item.environment,
extendEnv: true,
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}),
)
.pipe(
+15 -2
View File
@@ -68,6 +68,7 @@ export interface Options {
readonly cwd: string
readonly env?: Record<string, string>
readonly maxOutputBytes?: number
readonly stdin?: ChildProcess.CommandInput
}
export interface Interface {
@@ -85,6 +86,7 @@ export interface Interface {
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
readonly applyPatch: (cwd: string, patch: string) => Effect.Effect<Result>
}
const kind = (code: string): Kind => {
@@ -101,6 +103,8 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const encoder = new TextEncoder()
const stdin = (text: string) => Stream.make(encoder.encode(text))
const run = Effect.fn("Git.run")(
function* (args: string[], opts: Options) {
@@ -108,7 +112,7 @@ export const layer = Layer.effect(
cwd: opts.cwd,
env: opts.env,
extendEnv: true,
stdin: "ignore",
stdin: opts.stdin ?? "ignore",
stdout: "pipe",
stderr: "pipe",
})
@@ -316,9 +320,13 @@ export const layer = Layer.effect(
cwd,
maxOutputBytes: 4096,
})
if (result.truncated) return
const parts = result.text().split("\t")
const text = result.text()
const parts = text.split("\t")
if (parts.length < 2) return
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
return {
@@ -328,6 +336,10 @@ export const layer = Layer.effect(
} satisfies Stat
})
const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) {
return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) })
})
return Service.of({
run,
branch,
@@ -343,6 +355,7 @@ export const layer = Layer.effect(
patchAll,
patchUntracked,
statUntracked,
applyPatch,
})
}),
)
+84 -1
View File
@@ -6,7 +6,7 @@ import { InstanceState } from "@/effect/instance-state"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
import { zod } from "@/util/effect-zod"
import { zod, zodObject } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "vcs" })
@@ -239,11 +239,39 @@ export const FileDiff = Schema.Struct({
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type FileDiff = Schema.Schema.Type<typeof FileDiff>
export const FileStatus = Schema.Struct({
file: Schema.String,
additions: NonNegativeInt,
deletions: NonNegativeInt,
status: Schema.Literals(["added", "deleted", "modified"]),
})
.annotate({ identifier: "VcsFileStatus" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type FileStatus = Schema.Schema.Type<typeof FileStatus>
export const ApplyInput = Schema.Struct({
patch: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) })))
export type ApplyInput = Schema.Schema.Type<typeof ApplyInput>
export const ApplyResult = Schema.Struct({
applied: Schema.Boolean,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ApplyResult = Schema.Schema.Type<typeof ApplyResult>
export class PatchApplyError extends Schema.TaggedErrorClass<PatchApplyError>()("VcsPatchApplyError", {
message: Schema.String,
reason: Schema.Literals(["non-git", "not-clean"]),
}) {}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
readonly diffRaw: () => Effect.Effect<string>
readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
}
interface State {
@@ -304,6 +332,31 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
return yield* InstanceState.use(state, (x) => x.root?.name)
}),
status: Effect.fn("Vcs.status")(function* () {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
const [list, stats] = yield* Effect.all(
[git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])],
{ concurrency: 2 },
)
const map = nums(stats)
return yield* Effect.forEach(
list.toSorted((a, b) => a.file.localeCompare(b.file)),
(item) =>
Effect.gen(function* () {
const stat =
map.get(item.file) ??
(item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined)
return {
file: item.file,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
status: item.status,
} satisfies FileStatus
}),
)
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
const value = yield* InstanceState.get(state)
const ctx = yield* InstanceState.context
@@ -318,6 +371,36 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
if (!ref) return []
return yield* diffAgainstRef(git, ctx.directory, ref)
}),
diffRaw: Effect.fn("Vcs.diffRaw")(function* () {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return ""
const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], {
concurrency: 2,
})
const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : ""
const untracked = yield* Effect.forEach(
status.filter((item) => item.code === "??"),
(item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)),
)
return [tracked, ...untracked].filter(Boolean).join("\n")
}),
apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") {
return yield* new PatchApplyError({
message: "Patch can't be applied because the project is not git-based",
reason: "non-git",
})
}
const applied = yield* git.applyPatch(ctx.directory, input.patch)
if (applied.exitCode !== 0) {
return yield* new PatchApplyError({
message: "Patch can't be applied",
reason: "not-clean",
})
}
return { applied: true }
}),
})
}),
)
@@ -8,6 +8,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { WorkspaceAdapterEntry } from "@/control-plane/types"
import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
@@ -92,6 +93,23 @@ export const WorkspaceRoutes = lazy(() =>
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
},
)
.post(
"/sync-list",
describeRoute({
summary: "Sync workspace list",
description: "Register missing workspaces returned by workspace adapters.",
operationId: "experimental.workspace.syncList",
responses: {
204: {
description: "Workspace list synced",
},
},
}),
async (c) => {
await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project)))
return c.body(null, 204)
},
)
.get(
"/status",
describeRoute({
@@ -164,19 +182,47 @@ export const WorkspaceRoutes = lazy(() =>
z.object({
id: zodObject(Workspace.Info).shape.id.nullable(),
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
copyChanges: z.boolean().optional(),
}),
),
async (c) => {
const body = c.req.valid("json")
await AppRuntime.runPromise(
return AppRuntime.runPromise(
Workspace.Service.use((workspace) =>
workspace.sessionWarp({
workspaceID: body.id,
sessionID: body.sessionID,
copyChanges: body.copyChanges,
}),
).pipe(
Effect.match({
onFailure: (error) => {
if (error instanceof Vcs.PatchApplyError) {
return c.json(
{
name: "VcsApplyError",
data: {
message: error.message,
reason: error.reason,
},
},
400,
)
}
return c.json(
{
name: "WorkspaceWarpError",
data: {
message: error.message,
},
},
400,
)
},
onSuccess: () => c.body(null, 204),
}),
),
)
return c.body(null, 204)
},
),
)
@@ -5,7 +5,7 @@ import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
@@ -23,11 +23,25 @@ export const VcsDiffQuery = Schema.Struct({
mode: Vcs.Mode,
})
export class ApiVcsApplyError extends Schema.ErrorClass<ApiVcsApplyError>("VcsApplyError")(
{
name: Schema.Literal("VcsApplyError"),
data: Schema.Struct({
message: Schema.String,
reason: Schema.Literals(["non-git", "not-clean"]),
}),
},
{ httpApiStatus: 400 },
) {}
export const InstancePaths = {
dispose: "/instance/dispose",
path: "/path",
vcs: "/vcs",
vcsStatus: "/vcs/status",
vcsDiff: "/vcs/diff",
vcsDiffRaw: "/vcs/diff/raw",
vcsApply: "/vcs/apply",
command: "/command",
agent: "/agent",
skill: "/skill",
@@ -68,6 +82,15 @@ export const InstanceApi = HttpApi.make("instance")
"Retrieve version control system (VCS) information for the current project, such as git branch.",
}),
),
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.status",
summary: "Get VCS status",
description: "Retrieve changed files in the current working tree without patches.",
}),
),
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
query: VcsDiffQuery,
success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
@@ -78,6 +101,29 @@ export const InstanceApi = HttpApi.make("instance")
description: "Retrieve the current git diff for the working tree or against the default branch.",
}),
),
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
success: described(
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
"Raw VCS diff",
),
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.diff.raw",
summary: "Get raw VCS diff",
description: "Retrieve a raw patch for current uncommitted changes.",
}),
),
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
payload: Vcs.ApplyInput,
success: described(Vcs.ApplyResult, "VCS patch applied"),
error: ApiVcsApplyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.apply",
summary: "Apply VCS patch",
description: "Apply a raw patch to the current working tree.",
}),
),
HttpApiEndpoint.get("command", InstancePaths.command, {
success: described(Schema.Array(Command.Info), "List of commands"),
}).annotateMerge(
@@ -2,6 +2,7 @@ import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdapterEntry } from "@/control-plane/types"
import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ApiVcsApplyError } from "./instance"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
@@ -12,11 +13,23 @@ export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fie
export const WarpPayload = Schema.Struct({
id: Schema.NullOr(Workspace.Info.fields.id),
sessionID: Workspace.SessionWarpInput.fields.sessionID,
copyChanges: Workspace.SessionWarpInput.fields.copyChanges,
})
export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpError>("WorkspaceWarpError")(
{
name: Schema.Literal("WorkspaceWarpError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}
export const WorkspacePaths = {
adapters: `${root}/adapter`,
list: root,
syncList: `${root}/sync-list`,
status: `${root}/status`,
remove: `${root}/:id`,
warp: `${root}/warp`,
@@ -55,6 +68,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "Create a workspace for the current project.",
}),
),
HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
success: described(HttpApiSchema.NoContent, "Workspace list synced"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.syncList",
summary: "Sync workspace list",
description: "Register missing workspaces returned by workspace adapters.",
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
}).annotateMerge(
@@ -78,7 +100,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
payload: WarpPayload,
success: described(HttpApiSchema.NoContent, "Session warped"),
error: HttpApiError.BadRequest,
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.warp",
@@ -9,6 +9,7 @@ import { Skill } from "@/skill"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ApiVcsApplyError } from "../groups/instance"
import { markInstanceForDisposal } from "../lifecycle"
export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
@@ -41,10 +42,33 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
return { branch, default_branch }
})
const getVcsStatus = Effect.fn("InstanceHttpApi.vcsStatus")(function* () {
return yield* vcs.status()
})
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
return yield* vcs.diff(ctx.query.mode)
})
const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () {
return yield* vcs.diffRaw()
})
const applyVcs = Effect.fn("InstanceHttpApi.vcsApply")(function* (ctx: { payload: Vcs.ApplyInput }) {
return yield* vcs.apply(ctx.payload).pipe(
Effect.mapError(
(error) =>
new ApiVcsApplyError({
name: "VcsApplyError",
data: {
message: error.message,
reason: error.reason,
},
}),
),
)
})
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
return yield* command.list()
})
@@ -69,7 +93,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsStatus", getVcsStatus)
.handle("vcsDiff", getVcsDiff)
.handle("vcsDiffRaw", getVcsDiffRaw)
.handle("vcsApply", applyVcs)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
@@ -1,10 +1,12 @@
import { listAdapters } from "@/control-plane/adapters"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Vcs } from "@/project/vcs"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, WarpPayload } from "../groups/workspace"
import { ApiVcsApplyError } from "../groups/instance"
import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () {
@@ -12,7 +14,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => listAdapters(instance.project.id))
return yield* Effect.sync(() => listAdapters(instance.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
@@ -30,6 +32,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () {
yield* workspace.syncList((yield* InstanceState.context).project)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id))
return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID))
@@ -44,14 +50,34 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
.sessionWarp({
workspaceID: ctx.payload.id,
sessionID: ctx.payload.sessionID,
copyChanges: ctx.payload.copyChanges,
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
.pipe(
Effect.mapError((error) => {
if (error instanceof Vcs.PatchApplyError) {
return new ApiVcsApplyError({
name: "VcsApplyError",
data: {
message: error.message,
reason: error.reason,
},
})
}
return new ApiWorkspaceWarpError({
name: "WorkspaceWarpError",
data: {
message: error.message,
},
})
}),
)
})
return handlers
.handle("adapters", adapters)
.handle("list", list)
.handle("create", create)
.handle("syncList", syncList)
.handle("status", status)
.handle("remove", remove)
.handle("warp", warp)
@@ -27,7 +27,7 @@ import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event"
import { SyncRoutes } from "./sync"
import { InstanceMiddleware } from "./middleware"
import { jsonRequest } from "./trace"
import { jsonRequest, runRequest } from "./trace"
import { ExperimentalHttpApiServer } from "./httpapi/server"
import { EventPaths } from "./httpapi/event"
import { ExperimentalPaths } from "./httpapi/groups/experimental"
@@ -40,6 +40,7 @@ import { SyncPaths } from "./httpapi/groups/sync"
import { TuiPaths } from "./httpapi/groups/tui"
import { WorkspacePaths } from "./httpapi/groups/workspace"
import type { CorsOptions } from "@/server/cors"
import { errors } from "@/server/error"
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
const app = new Hono()
@@ -86,7 +87,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context))
app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context))
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
@@ -288,6 +292,98 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
return yield* vcs.diff(c.req.valid("query").mode)
}),
)
.get(
"/vcs/status",
describeRoute({
summary: "Get VCS status",
description: "Retrieve changed files in the current working tree without patches.",
operationId: "vcs.status",
responses: {
200: {
description: "VCS status",
content: {
"application/json": {
schema: resolver(Vcs.FileStatus.zod.array()),
},
},
},
},
}),
async (c) =>
jsonRequest("InstanceRoutes.vcs.status", c, function* () {
const vcs = yield* Vcs.Service
return yield* vcs.status()
}),
)
.get(
"/vcs/diff/raw",
describeRoute({
summary: "Get raw VCS diff",
description: "Retrieve a raw patch for current uncommitted changes.",
operationId: "vcs.diff.raw",
responses: {
200: {
description: "Raw VCS diff",
content: {
"text/x-diff": {
schema: resolver(z.string()),
},
},
},
},
}),
async (c) => {
const patch = await runRequest(
"InstanceRoutes.vcs.diffRaw",
c,
Vcs.Service.use((vcs) => vcs.diffRaw()),
)
return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" })
},
)
.post(
"/vcs/apply",
describeRoute({
summary: "Apply VCS patch",
description: "Apply a raw patch to the current working tree.",
operationId: "vcs.apply",
responses: {
200: {
description: "VCS patch applied",
content: {
"application/json": {
schema: resolver(Vcs.ApplyResult.zod),
},
},
},
...errors(400),
},
}),
validator("json", Vcs.ApplyInput.zodObject),
async (c) => {
const result = await runRequest(
"InstanceRoutes.vcs.apply",
c,
Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe(
Effect.match({
onFailure: (error) => ({ ok: false as const, error }),
onSuccess: (value) => ({ ok: true as const, value }),
}),
),
)
if (result.ok) return c.json(result.value)
return c.json(
{
name: "VcsApplyError",
data: {
message: result.error.message,
reason: result.error.reason,
},
},
400,
)
},
)
.get(
"/command",
describeRoute({
@@ -5,6 +5,7 @@ import { SyncEvent } from "@/sync"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { WorkspaceTable } from "@/control-plane/workspace.sql"
import { Log } from "@opencode-ai/core/util/log"
import nextProjectors from "./projectors-next"
@@ -69,6 +70,10 @@ export default [
db.insert(SessionTable)
.values(Session.toRow(data.info as Session.Info))
.run()
if (data.info.workspaceID) {
db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run()
}
}),
SyncEvent.project(Session.Event.Updated, (db, data) => {
+5
View File
@@ -63,6 +63,11 @@ export function truncate(str: string, len: number): string {
return str.slice(0, len - 1) + "…"
}
export function truncateLeft(str: string, len: number): string {
if (str.length <= len) return str
return "…" + str.slice(-(len - 1))
}
export function truncateMiddle(str: string, maxLength: number = 35): string {
if (str.length <= maxLength) return str
+37 -1
View File
@@ -117,6 +117,13 @@ export const ResetFailedError = NamedError.create(
}),
)
export const ListFailedError = NamedError.create(
"WorktreeListFailedError",
z.object({
message: z.string(),
}),
)
function slugify(input: string) {
return input
.trim()
@@ -149,6 +156,7 @@ export interface Interface {
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]>
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
}
@@ -341,6 +349,34 @@ export const layer: Layer.Layer<
return undefined
})
const list = Effect.fn("Worktree.list")(function* () {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") {
return []
}
const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
if (result.code !== 0) {
throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
}
const primary = yield* canonical(ctx.worktree)
const primaryName = pathSvc.basename(primary)
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
Effect.gen(function* () {
if (!entry.path) return undefined
const directory = yield* canonical(entry.path)
if (directory === primary) return undefined
const name = pathSvc.basename(directory)
return {
name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name,
directory,
...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}),
}
}),
).pipe(Effect.map((items) => items.filter((item) => item !== undefined)))
})
function stopFsmonitor(target: string) {
return fs.exists(target).pipe(
Effect.orDie,
@@ -579,7 +615,7 @@ export const layer: Layer.Layer<
return true
})
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset })
}),
)
@@ -34,10 +34,11 @@ describe("acp.agent interface compliance", () => {
"loadSession",
"setSessionMode",
"authenticate",
// Unstable - SDK checks these with unstable_ prefix
// Capability-gated methods checked by the SDK router
"listSessions",
"resumeSession",
"closeSession",
"unstable_forkSession",
"unstable_resumeSession",
"unstable_setSessionModel",
]
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create"
describe("recentConnectedWorkspaces", () => {
test("returns unique connected workspaces after filtering missing and inactive entries", () => {
test("returns connected workspaces sorted by time used", () => {
const workspaces = [
{ id: "wrk_a", name: "alpha" },
{ id: "wrk_b", name: "beta" },
{ id: "wrk_c", name: "gamma" },
{ id: "wrk_d", name: "delta" },
{ id: "wrk_e", name: "epsilon" },
{ id: "wrk_a", name: "alpha", timeUsed: 700 },
{ id: "wrk_b", name: "beta", timeUsed: 800 },
{ id: "wrk_c", name: "gamma", timeUsed: 400 },
{ id: "wrk_d", name: "delta", timeUsed: 300 },
{ id: "wrk_e", name: "epsilon", timeUsed: 200 },
]
const status = {
wrk_a: "connected",
@@ -19,17 +19,7 @@ describe("recentConnectedWorkspaces", () => {
} as const
const { recent } = recentConnectedWorkspaces({
sessions: [
{ time: { updated: 900 } },
{ workspaceID: "wrk_b", time: { updated: 800 } },
{ workspaceID: "wrk_a", time: { updated: 700 } },
{ workspaceID: "wrk_a", time: { updated: 600 } },
{ workspaceID: "wrk_missing", time: { updated: 500 } },
{ workspaceID: "wrk_c", time: { updated: 400 } },
{ workspaceID: "wrk_d", time: { updated: 300 } },
{ workspaceID: "wrk_e", time: { updated: 200 } },
],
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
workspaces,
status: (workspaceID) => status[workspaceID as keyof typeof status],
})
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { normalizeCustomProviderID, providerOptions } from "../../../../src/cli/cmd/tui/component/dialog-provider"
describe("providerOptions", () => {
test("includes a synthetic Other option for custom providers", () => {
expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({
title: "Other",
description: "Custom provider",
category: "Providers",
})
})
test("does not use Other as the generic provider category", () => {
expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers")
})
test("does not collide with a configured provider named other", () => {
const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value)
expect(new Set(values).size).toBe(values.length)
})
test("normalizes and validates custom provider ids", () => {
expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider")
expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider")
expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider")
expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined()
expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined()
})
})
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { $ } from "bun"
import fs from "node:fs/promises"
import Http from "node:http"
import path from "node:path"
@@ -27,14 +28,19 @@ import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
import * as WorkspaceOld from "../../src/control-plane/workspace"
import * as Workspace from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
void Log.init({ print: false })
const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
WorkspaceOld.defaultLayer,
Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
),
SessionNs.defaultLayer,
)
const it = testEffect(testServerLayer)
@@ -58,6 +64,7 @@ type RecordedAdapter = {
calls: {
configure: WorkspaceInfo[]
create: RecordedCreate[]
list: number
remove: WorkspaceInfo[]
target: WorkspaceInfo[]
}
@@ -107,23 +114,37 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
})
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id)))
const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status()))
async function initGitRepo(dir: string) {
await fs.mkdir(dir, { recursive: true })
await $`git init`.cwd(dir).quiet()
await $`git config core.fsmonitor false`.cwd(dir).quiet()
await $`git config commit.gpgsign false`.cwd(dir).quiet()
await $`git config user.email "test@opencode.test"`.cwd(dir).quiet()
await $`git config user.name "Test"`.cwd(dir).quiet()
await fs.writeFile(path.join(dir, "tracked.txt"), "base\n")
await $`git add tracked.txt`.cwd(dir).quiet()
await $`git commit -m "base"`.cwd(dir).quiet()
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: Workspace.CreateInput) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
const warpWorkspaceSession = (input: Workspace.SessionWarpInput) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.sessionWarp(input)))
const listWorkspaces = (project: Parameters<Workspace.Interface["list"]>[0]) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.list(project)))
const syncListWorkspaces = (project: Parameters<Workspace.Interface["syncList"]>[0]) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.syncList(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.get(id)))
const removeWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.remove(id)))
const workspaceStatus = () => runWorkspace(Workspace.Service.use((workspace) => workspace.status()))
const isWorkspaceSyncing = (id: WorkspaceID) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id)))
runWorkspace(Workspace.Service.use((workspace) => workspace.isSyncing(id)))
const startWorkspaceSyncing = (projectID: ProjectID) => {
void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
}
const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
function captureGlobalEvents() {
const events: GlobalEvent[] = []
@@ -169,11 +190,13 @@ function recordedAdapter(input: {
target: (info: WorkspaceInfo) => Target | Promise<Target>
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
list?: () => Omit<WorkspaceInfo, "id">[] | Promise<Omit<WorkspaceInfo, "id">[]>
remove?: (info: WorkspaceInfo) => Promise<void>
}): RecordedAdapter {
const calls: RecordedAdapter["calls"] = {
configure: [],
create: [],
list: 0,
remove: [],
target: [],
}
@@ -195,6 +218,14 @@ function recordedAdapter(input: {
})
await input.create?.(info, env, from)
},
...(input.list
? {
async list() {
calls.list += 1
return input.list?.() ?? []
},
}
: {}),
async remove(info) {
calls.remove.push(structuredClone(info))
await input.remove?.(info)
@@ -254,7 +285,7 @@ function serverUrl() {
})
}
function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<WorkspaceInfo>): WorkspaceInfo {
function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Workspace.Info>): Workspace.Info {
return {
id: input?.id ?? WorkspaceID.ascending(),
type,
@@ -263,10 +294,11 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Works
directory: input?.directory ?? null,
extra: input?.extra ?? null,
projectID,
timeUsed: input?.timeUsed ?? Date.now(),
}
}
function insertWorkspace(info: WorkspaceInfo) {
function insertWorkspace(info: Workspace.Info) {
Database.use((db) =>
db
.insert(WorkspaceTable)
@@ -278,6 +310,7 @@ function insertWorkspace(info: WorkspaceInfo) {
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run(),
)
@@ -330,11 +363,11 @@ function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
describe("workspace-old schemas and exports", () => {
describe("workspace schemas and exports", () => {
test("keeps the historical event type names", () => {
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
expect(Workspace.Event.Ready.type).toBe("workspace.ready")
expect(Workspace.Event.Failed.type).toBe("workspace.failed")
expect(Workspace.Event.Status.type).toBe("workspace.status")
})
test("validates create input with workspace id, project id, branch, type, and extra", () => {
@@ -346,13 +379,13 @@ describe("workspace-old schemas and exports", () => {
extra: { nested: true },
}
expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input)
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
expect(Workspace.CreateInput.zod.parse(input)).toEqual(input)
expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => Workspace.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
})
})
describe("workspace-old CRUD", () => {
describe("workspace CRUD", () => {
test("get returns undefined for a missing workspace", async () => {
await withInstance(async () => {
expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined()
@@ -429,13 +462,22 @@ describe("workspace-old CRUD", () => {
directory: targetDir,
extra: { configured: true },
projectID: Instance.project.id,
timeUsed: info.timeUsed,
})
expect(await getWorkspace(workspaceID)).toEqual(info)
expect(await listWorkspaces(Instance.project)).toEqual([info])
expect(recorded.calls.configure).toHaveLength(1)
expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null })
expect(recorded.calls.create).toHaveLength(1)
expect(recorded.calls.create[0].info).toEqual(info)
expect(recorded.calls.create[0].info).toEqual({
id: workspaceID,
type,
branch: "configured-branch",
name: "Configured Name",
directory: targetDir,
extra: { configured: true },
projectID: Instance.project.id,
})
expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({
test: { type: "api", key: "secret" },
})
@@ -514,6 +556,120 @@ describe("workspace-old CRUD", () => {
})
})
test("syncList registers adapter-listed workspaces that are missing by name", async () => {
await withInstance(async (dir) => {
const type = unique("list-sync")
const existing = workspaceInfo(Instance.project.id, type, {
id: WorkspaceID.ascending("wrk_list_sync_existing"),
name: "existing",
directory: path.join(dir, "existing"),
})
insertWorkspace(existing)
const discovered = {
type,
name: "discovered",
branch: "feature/discovered",
directory: path.join(dir, "discovered"),
extra: { source: "adapter" },
projectID: Instance.project.id,
}
const recorded = recordedAdapter({
list() {
return [
{
type,
name: existing.name,
branch: "ignored",
directory: path.join(dir, "ignored"),
extra: null,
projectID: Instance.project.id,
},
discovered,
]
},
target(info) {
return { type: "local", directory: info.directory ?? dir }
},
})
registerAdapter(Instance.project.id, type, recorded.adapter)
await syncListWorkspaces(Instance.project)
const synced = (await listWorkspaces(Instance.project)).filter((item) => item.name === discovered.name)
expect(synced).toHaveLength(1)
expect(synced[0]).toMatchObject(discovered)
expect(synced[0]?.id).toStartWith("wrk_")
expect(await listWorkspaces(Instance.project)).toEqual(expect.arrayContaining([existing, synced[0]]))
expect(recorded.calls.list).toBe(1)
expect(recorded.calls.configure).toHaveLength(0)
expect(recorded.calls.create).toHaveLength(0)
expect(recorded.calls.target).toHaveLength(1)
})
})
test("syncList calls every registered adapter with a list method", async () => {
await withInstance(async (dir) => {
const typeA = unique("list-sync-a")
const typeB = unique("list-sync-b")
const adapterA = recordedAdapter({
list() {
return [
{
type: typeA,
name: "adapter-a",
branch: null,
directory: path.join(dir, "adapter-a"),
extra: null,
projectID: Instance.project.id,
},
]
},
target(info) {
return { type: "local", directory: info.directory ?? dir }
},
})
const adapterB = recordedAdapter({
list() {
return [
{
type: typeB,
name: "adapter-b",
branch: null,
directory: path.join(dir, "adapter-b"),
extra: null,
projectID: Instance.project.id,
},
]
},
target(info) {
return { type: "local", directory: info.directory ?? dir }
},
})
const noList = recordedAdapter({
target() {
return { type: "local", directory: dir }
},
})
registerAdapter(Instance.project.id, typeA, adapterA.adapter)
registerAdapter(Instance.project.id, typeB, adapterB.adapter)
registerAdapter(Instance.project.id, unique("list-sync-none"), noList.adapter)
await syncListWorkspaces(Instance.project)
const synced = await listWorkspaces(Instance.project)
expect(
synced
.filter((item) => item.type === typeA || item.type === typeB)
.map((item) => item.name)
.toSorted(),
).toEqual(["adapter-a", "adapter-b"])
expect(adapterA.calls.list).toBe(1)
expect(adapterB.calls.list).toBe(1)
expect(noList.calls.list).toBe(0)
})
})
it.live("remote create connects to routed event and history endpoints", () => {
const calls: FetchCall[] = []
return Effect.gen(function* () {
@@ -539,7 +695,7 @@ describe("workspace-old CRUD", () => {
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const type = unique("remote-create")
const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir })
registerAdapter(Instance.project.id, type, recorded.adapter)
@@ -644,6 +800,33 @@ describe("workspace-old CRUD", () => {
})
})
test("sessionWarp applies source workspace patch to local target workspace", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-patch-prev-local")
const targetType = unique("warp-patch-target-local")
const previousDir = path.join(dir, "warp-patch-prev-local")
const targetDir = path.join(dir, "warp-patch-target-local")
await initGitRepo(previousDir)
await initGitRepo(targetDir)
await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")
await fs.writeFile(path.join(previousDir, "new.txt"), "new\n")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n")
expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n")
})
})
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-detach-local")
@@ -696,10 +879,12 @@ describe("workspace-old CRUD", () => {
},
])
}
if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch")
if (call.url.pathname === "/warp-target/sync/replay")
return yield* HttpServerResponse.json({ sessionID: "ok" })
if (call.url.pathname === "/warp-target/sync/steal")
return yield* HttpServerResponse.json({ sessionID: "ok" })
if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true })
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
@@ -707,7 +892,7 @@ describe("workspace-old CRUD", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-remote-source")
const targetType = unique("warp-remote-target")
@@ -722,15 +907,18 @@ describe("workspace-old CRUD", () => {
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
"POST /warp-source/sync/history",
"GET /warp-source/vcs/diff/raw",
"POST /warp-target/vcs/apply",
"POST /warp-target/sync/replay",
"POST /warp-target/sync/steal",
])
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
expect(calls[1].json).toMatchObject({
expect(calls[2].json).toEqual({ patch: "remote patch" })
expect(calls[3].json).toMatchObject({
directory: "remote-target-dir",
events: [
{
@@ -745,7 +933,7 @@ describe("workspace-old CRUD", () => {
},
],
})
expect(calls[2].json).toEqual({ sessionID: session.id })
expect(calls[4].json).toEqual({ sessionID: session.id })
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
expect(sessionSequenceOwner(session.id)).toBe(target.id)
}),
@@ -755,7 +943,7 @@ describe("workspace-old CRUD", () => {
})
})
describe("workspace-old sync state", () => {
describe("workspace sync state", () => {
test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => {
await withInstance(async (dir) => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
@@ -773,35 +961,29 @@ describe("workspace-old sync state", () => {
})
})
test("startWorkspaceSyncing starts only workspaces with sessions", async () => {
test("startWorkspaceSyncing starts all workspaces", async () => {
await withInstance(async (dir) => {
const withSessionType = unique("with-session")
const withoutSessionType = unique("without-session")
const withSession = workspaceInfo(Instance.project.id, withSessionType)
const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType)
const withSessionDir = path.join(dir, "with-session")
const withoutSessionDir = path.join(dir, "without-session")
await fs.mkdir(withSessionDir, { recursive: true })
await fs.mkdir(withoutSessionDir, { recursive: true })
insertWorkspace(withSession)
insertWorkspace(withoutSession)
registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter)
registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
withSession.id,
)
const firstType = unique("first")
const secondType = unique("second")
const first = workspaceInfo(Instance.project.id, firstType)
const second = workspaceInfo(Instance.project.id, secondType)
await fs.mkdir(path.join(dir, "first"), { recursive: true })
await fs.mkdir(path.join(dir, "second"), { recursive: true })
insertWorkspace(first)
insertWorkspace(second)
registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter)
registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter)
startWorkspaceSyncing(Instance.project.id)
await eventually(() =>
workspaceStatus().then((status) =>
expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"),
),
workspaceStatus().then((status) => {
expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected")
expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected")
}),
)
expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined()
await removeWorkspace(withSession.id)
await removeWorkspace(withoutSession.id)
await removeWorkspace(first.id)
await removeWorkspace(second.id)
})
})
@@ -857,7 +1039,7 @@ describe("workspace-old sync state", () => {
)
expect(
captured.events.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
(event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type,
),
).toHaveLength(1)
await removeWorkspace(info.id)
@@ -891,7 +1073,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
@@ -915,7 +1097,7 @@ describe("workspace-old sync state", () => {
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
(event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type,
)
.map((event) => event.payload.properties.status),
).toEqual(["disconnected", "connecting", "connected"])
@@ -948,7 +1130,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("remote-connect-fail")
const info = workspaceInfo(Instance.project.id, type)
@@ -988,7 +1170,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("remote-history-fail")
const info = workspaceInfo(Instance.project.id, type)
@@ -1043,7 +1225,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
@@ -1110,7 +1292,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
@@ -1191,7 +1373,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
@@ -1230,7 +1412,7 @@ describe("workspace-old sync state", () => {
})
})
describe("workspace-old waitForSync", () => {
describe("workspace waitForSync", () => {
test("returns immediately for an empty fence", async () => {
await withInstance(async () => {
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined()
@@ -12,8 +12,14 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Flag } = await import("@opencode-ai/core/flag/flag")
const { Plugin } = await import("../../src/plugin/index")
const { Workspace } = await import("../../src/control-plane/workspace")
const { InstanceBootstrap } = await import("../../src/project/bootstrap")
const { Instance } = await import("../../src/project/instance")
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer))
const { InstanceStore } = await import("../../src/project/instance-store")
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
@@ -200,6 +200,35 @@ describe("Worktree", () => {
)
})
describe("list", () => {
it.live("uses parent folder name when worktree basename matches the primary worktree", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`)
const target = path.join(parent, path.basename(dir))
const branch = `same-basename-list-${Date.now()}`
yield* Effect.promise(() => fs.mkdir(parent, { recursive: true }))
yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet())
const list = yield* svc.list()
const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target))
expect(list).toContainEqual({
name: path.basename(parent),
branch,
directory,
})
yield* svc.remove({ directory: target })
}),
{ git: true },
),
)
})
describe("remove edge cases", () => {
it.live("remove non-existent directory succeeds silently", () =>
provideTmpdirInstance(
@@ -10,8 +10,10 @@ import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { Instance } from "../../src/project/instance"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
@@ -36,6 +38,11 @@ const testStateLayer = Layer.effectDiscard(
}),
)
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
@@ -43,7 +50,7 @@ const it = testEffect(
NodeServices.layer,
InstanceLayer.layer,
Project.defaultLayer,
Workspace.defaultLayer,
workspaceLayer,
),
)
@@ -1,7 +1,7 @@
import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
@@ -9,6 +9,8 @@ import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
@@ -30,6 +32,10 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
@@ -106,7 +112,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
extra: null,
projectID: input.projectID,
}),
).pipe(Effect.provide(Workspace.defaultLayer))
).pipe(Effect.provide(workspaceLayer))
})
function request(path: string, init?: RequestInit) {
@@ -20,6 +20,8 @@ import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
@@ -45,13 +47,18 @@ const testStateLayer = Layer.effectDiscard(
}),
)
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
Project.defaultLayer,
Workspace.defaultLayer,
workspaceLayer,
Socket.layerWebSocketConstructorGlobal,
),
)
@@ -14,6 +14,8 @@ import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { WorkspaceRef } from "../../src/effect/instance-ref"
@@ -23,9 +25,11 @@ void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const it = testEffect(
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
return Effect.promise(() => {
@@ -60,6 +64,36 @@ function localAdapter(directory: string): WorkspaceAdapter {
}
}
function listedAdapter(directory: string, type: string): WorkspaceAdapter {
return {
name: "Listed Test",
description: "List a local test workspace",
configure(info) {
return { ...info, name: "unused", directory }
},
async create() {},
async remove() {},
list() {
return [
{
type,
name: "listed-test",
branch: "listed/main",
directory,
extra: { listed: true },
projectID: Instance.project.id,
},
]
},
target() {
return {
type: "local" as const,
directory,
}
},
}
}
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
return {
name: "Remote Test",
@@ -192,6 +226,30 @@ describe("workspace HttpApi", () => {
}),
)
it.live("serves list sync endpoint", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const type = `listed-${Math.random().toString(36).slice(2)}`
registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type))
const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" })
expect(response.status).toBe(204)
const listed = yield* request(WorkspacePaths.list, dir)
expect(yield* Effect.promise(() => listed.json())).toMatchObject([
{
type,
name: "listed-test",
branch: "listed/main",
directory: path.join(dir, ".listed"),
extra: { listed: true },
},
])
}),
)
it.live("creates workspace with the TUI payload shape", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.39",
"version": "1.14.41",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.39",
"version": "1.14.41",
"type": "module",
"license": "MIT",
"scripts": {
+141
View File
@@ -36,6 +36,7 @@ import type {
ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceStatusResponses,
ExperimentalWorkspaceSyncListResponses,
ExperimentalWorkspaceWarpErrors,
ExperimentalWorkspaceWarpResponses,
FileListResponses,
@@ -202,8 +203,12 @@ import type {
V2SessionMessagesResponses,
V2SessionPromptResponses,
V2SessionWaitResponses,
VcsApplyErrors,
VcsApplyResponses,
VcsDiffRawResponses,
VcsDiffResponses,
VcsGetResponses,
VcsStatusResponses,
WorktreeCreateErrors,
WorktreeCreateInput,
WorktreeCreateResponses,
@@ -945,6 +950,36 @@ export class Workspace extends HeyApiClient {
})
}
/**
* Sync workspace list
*
* Register missing workspaces returned by workspace adapters.
*/
public syncList<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<ExperimentalWorkspaceSyncListResponses, unknown, ThrowOnError>({
url: "/experimental/workspace/sync-list",
...options,
...params,
})
}
/**
* Workspace status
*
@@ -1022,6 +1057,7 @@ export class Workspace extends HeyApiClient {
workspace?: string
id?: string | null
sessionID?: string
copyChanges?: boolean
},
options?: Options<never, ThrowOnError>,
) {
@@ -1034,6 +1070,7 @@ export class Workspace extends HeyApiClient {
{ in: "query", key: "workspace" },
{ in: "body", key: "id" },
{ in: "body", key: "sessionID" },
{ in: "body", key: "copyChanges" },
],
},
],
@@ -1555,6 +1592,38 @@ export class Path extends HeyApiClient {
}
}
export class Diff extends HeyApiClient {
/**
* Get raw VCS diff
*
* Retrieve a raw patch for current uncommitted changes.
*/
public raw<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<VcsDiffRawResponses, unknown, ThrowOnError>({
url: "/vcs/diff/raw",
...options,
...params,
})
}
}
export class Vcs extends HeyApiClient {
/**
* Get VCS info
@@ -1586,6 +1655,36 @@ export class Vcs extends HeyApiClient {
})
}
/**
* Get VCS status
*
* Retrieve changed files in the current working tree without patches.
*/
public status<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<VcsStatusResponses, unknown, ThrowOnError>({
url: "/vcs/status",
...options,
...params,
})
}
/**
* Get VCS diff
*
@@ -1617,6 +1716,48 @@ export class Vcs extends HeyApiClient {
...params,
})
}
/**
* Apply VCS patch
*
* Apply a raw patch to the current working tree.
*/
public apply<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
patch?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "patch" },
],
},
],
)
return (options?.client ?? this.client).post<VcsApplyResponses, VcsApplyErrors, ThrowOnError>({
url: "/vcs/apply",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _diff?: Diff
get diff2(): Diff {
return (this._diff ??= new Diff({ client: this.client }))
}
}
export class Command extends HeyApiClient {
+116 -2
View File
@@ -1474,6 +1474,13 @@ export type VcsInfo = {
default_branch?: string
}
export type VcsFileStatus = {
file: string
additions: number
deletions: number
status: "added" | "deleted" | "modified"
}
export type VcsFileDiff = {
file: string
patch: string
@@ -1482,6 +1489,14 @@ export type VcsFileDiff = {
status?: "added" | "deleted" | "modified"
}
export type VcsApplyError = {
name: "VcsApplyError"
data: {
message: string
reason: "non-git" | "not-clean"
}
}
export type Command = {
name: string
description?: string
@@ -1734,6 +1749,14 @@ export type Workspace = {
directory: string | null
extra: unknown | null
projectID: string
timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
export type WorkspaceWarpError = {
name: "WorkspaceWarpError"
data: {
message: string
}
}
export type SyncEventMessageUpdated = {
@@ -4020,6 +4043,25 @@ export type VcsGetResponses = {
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
export type VcsStatusData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/vcs/status"
}
export type VcsStatusResponses = {
/**
* VCS status
*/
200: Array<VcsFileStatus>
}
export type VcsStatusResponse = VcsStatusResponses[keyof VcsStatusResponses]
export type VcsDiffData = {
body?: never
path?: never
@@ -4040,6 +4082,57 @@ export type VcsDiffResponses = {
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]
export type VcsDiffRawData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/vcs/diff/raw"
}
export type VcsDiffRawResponses = {
/**
* Raw VCS diff
*/
200: string
}
export type VcsDiffRawResponse = VcsDiffRawResponses[keyof VcsDiffRawResponses]
export type VcsApplyData = {
body?: {
patch: string
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/vcs/apply"
}
export type VcsApplyErrors = {
/**
* VcsApplyError
*/
400: VcsApplyError
}
export type VcsApplyError2 = VcsApplyErrors[keyof VcsApplyErrors]
export type VcsApplyResponses = {
/**
* VCS patch applied
*/
200: {
applied: boolean
}
}
export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses]
export type CommandListData = {
body?: never
path?: never
@@ -6608,6 +6701,26 @@ export type ExperimentalWorkspaceCreateResponses = {
export type ExperimentalWorkspaceCreateResponse =
ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
export type ExperimentalWorkspaceSyncListData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/workspace/sync-list"
}
export type ExperimentalWorkspaceSyncListResponses = {
/**
* Workspace list synced
*/
204: void
}
export type ExperimentalWorkspaceSyncListResponse =
ExperimentalWorkspaceSyncListResponses[keyof ExperimentalWorkspaceSyncListResponses]
export type ExperimentalWorkspaceStatusData = {
body?: never
path?: never
@@ -6667,6 +6780,7 @@ export type ExperimentalWorkspaceWarpData = {
body?: {
id: string | null
sessionID: string
copyChanges?: boolean
}
path?: never
query?: {
@@ -6678,9 +6792,9 @@ export type ExperimentalWorkspaceWarpData = {
export type ExperimentalWorkspaceWarpErrors = {
/**
* Bad request
* WorkspaceWarpError | VcsApplyError
*/
400: BadRequestError
400: WorkspaceWarpError | VcsApplyError
}
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
+311 -3
View File
@@ -1897,6 +1897,54 @@
]
}
},
"/vcs/status": {
"get": {
"tags": ["instance"],
"operationId": "vcs.status",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "VCS status",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VcsFileStatus"
},
"description": "VCS status"
}
}
}
}
},
"description": "Retrieve changed files in the current working tree without patches.",
"summary": "Get VCS status",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.status({\n ...\n})"
}
]
}
},
"/vcs/diff": {
"get": {
"tags": ["instance"],
@@ -1954,6 +2002,128 @@
]
}
},
"/vcs/diff/raw": {
"get": {
"tags": ["instance"],
"operationId": "vcs.diff.raw",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Raw VCS diff",
"content": {
"text/x-diff; charset=utf-8": {
"schema": {
"type": "string"
}
}
}
}
},
"description": "Retrieve a raw patch for current uncommitted changes.",
"summary": "Get raw VCS diff",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff.raw({\n ...\n})"
}
]
}
},
"/vcs/apply": {
"post": {
"tags": ["instance"],
"operationId": "vcs.apply",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "VCS patch applied",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"applied": {
"type": "boolean"
}
},
"required": ["applied"],
"additionalProperties": false,
"description": "VCS patch applied"
}
}
}
},
"400": {
"description": "VcsApplyError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/VcsApplyError"
}
}
}
}
},
"description": "Apply a raw patch to the current working tree.",
"summary": "Apply VCS patch",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"patch": {
"type": "string"
}
},
"required": ["patch"],
"additionalProperties": false
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.apply({\n ...\n})"
}
]
}
},
"/command": {
"get": {
"tags": ["instance"],
@@ -8247,6 +8417,43 @@
]
}
},
"/experimental/workspace/sync-list": {
"post": {
"tags": ["workspace"],
"operationId": "experimental.workspace.syncList",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Workspace list synced"
}
},
"description": "Register missing workspaces returned by workspace adapters.",
"summary": "Sync workspace list",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.syncList({\n ...\n})"
}
]
}
},
"/experimental/workspace/status": {
"get": {
"tags": ["workspace"],
@@ -8396,11 +8603,18 @@
"description": "Session warped"
},
"400": {
"description": "Bad request",
"description": "WorkspaceWarpError | VcsApplyError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
"anyOf": [
{
"$ref": "#/components/schemas/WorkspaceWarpError"
},
{
"$ref": "#/components/schemas/VcsApplyError"
}
]
}
}
}
@@ -8426,6 +8640,9 @@
},
"sessionID": {
"type": "string"
},
"copyChanges": {
"type": "boolean"
}
},
"required": ["id", "sessionID"],
@@ -12665,6 +12882,28 @@
},
"additionalProperties": false
},
"VcsFileStatus": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"additions": {
"type": "integer",
"minimum": 0
},
"deletions": {
"type": "integer",
"minimum": 0
},
"status": {
"type": "string",
"enum": ["added", "deleted", "modified"]
}
},
"required": ["file", "additions", "deletions", "status"],
"additionalProperties": false
},
"VcsFileDiff": {
"type": "object",
"properties": {
@@ -12690,6 +12929,31 @@
"required": ["file", "patch", "additions", "deletions"],
"additionalProperties": false
},
"VcsApplyError": {
"type": "object",
"properties": {
"name": {
"type": "string",
"enum": ["VcsApplyError"]
},
"data": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"reason": {
"type": "string",
"enum": ["non-git", "not-clean"]
}
},
"required": ["message", "reason"],
"additionalProperties": false
}
},
"required": ["name", "data"],
"additionalProperties": false
},
"Command": {
"type": "object",
"properties": {
@@ -13426,9 +13690,53 @@
},
"projectID": {
"type": "string"
},
"timeUsed": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["NaN"]
},
{
"type": "string",
"enum": ["Infinity"]
},
{
"type": "string",
"enum": ["-Infinity"]
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
}
},
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID"],
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"],
"additionalProperties": false
},
"WorkspaceWarpError": {
"type": "object",
"properties": {
"name": {
"type": "string",
"enum": ["WorkspaceWarpError"]
},
"data": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": ["message"],
"additionalProperties": false
}
},
"required": ["name", "data"],
"additionalProperties": false
},
"SyncEventMessageUpdated": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.39",
"version": "1.14.41",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.39",
"version": "1.14.41",
"type": "module",
"license": "MIT",
"exports": {
+6 -2
View File
@@ -128,8 +128,12 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
}
: {
oldFile: local.before,
newFile: local.after,
oldFile: local.before
? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" }
: local.before,
newFile: local.after
? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" }
: local.after,
lineAnnotations: annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
+2 -2
View File
@@ -1906,11 +1906,11 @@ ToolRegistry.register({
mode="diff"
before={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.before || props.input.oldString,
contents: props.metadata?.filediff?.before || props.input.oldString || "",
}}
after={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.after || props.input.newString,
contents: props.metadata?.filediff?.after || props.input.newString || "",
}}
/>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.39",
"version": "1.14.41",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -165,7 +165,7 @@ https://opencode.ai/zen/v1/models
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
قد تلاحظ _Claude Haiku 3.5_ في سجل الاستخدام. هذا [نموذج منخفض التكلفة](/docs/config/#models) يُستخدم لتوليد عناوين جلساتك.
+1 -1
View File
@@ -172,7 +172,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Možda ćete primijetiti _Claude Haiku 3.5_ u historiji korištenja. To je [low cost model](/docs/config/#models) koji se koristi za generisanje naslova vaših sesija.
+1 -1
View File
@@ -172,7 +172,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Du vil måske bemærke _Claude Haiku 3.5_ i din brugshistorik. Det er en [lavprismodel](/docs/config/#models), som bruges til at generere titlerne på dine sessioner.
+1 -1
View File
@@ -161,7 +161,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Möglicherweise siehst du _Claude Haiku 3.5_ in deinem Nutzungsverlauf. Das ist ein [kostengünstiges Modell](/docs/config/#models), das verwendet wird, um die Titel deiner Sessions zu generieren.
@@ -52,6 +52,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode |
| [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | Trace and debug your AI agents with Sentry AI Monitoring |
| [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI |
| [opencode-jfrog-plugin](https://github.com/jfrog/opencode-jfrog-plugin) | JFrog Plugin for seamless integration of Opencode users to JFrog platform |
---
+1 -1
View File
@@ -172,7 +172,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Puede que notes _Claude Haiku 3.5_ en tu historial de uso. Este es un [modelo de bajo costo](/docs/config/#models) que se usa para generar los títulos de tus sesiones.
+1 -1
View File
@@ -161,7 +161,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Vous remarquerez peut-être _Claude Haiku 3.5_ dans votre historique d'utilisation. Il s'agit d'un [modèle à faible coût](/docs/config/#models) utilisé pour générer les titres de vos sessions.
+1 -1
View File
@@ -172,7 +172,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Potresti notare _Claude Haiku 3.5_ nella cronologia di utilizzo. È un [modello a basso costo](/docs/config/#models) usato per generare i titoli delle tue sessioni.
+1 -1
View File
@@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
使用履歴に _Claude Haiku 3.5_ が表示されることがあります。これはセッションのタイトル生成に使われる [low cost model](/docs/config/#models) です。
+1 -1
View File
@@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
사용 기록에서 *Claude Haiku 3.5*를 볼 수 있습니다. 이는 세션 제목을 생성할 때 사용되는 [저비용 모델](/docs/config/#models)입니다.
+1 -1
View File
@@ -172,7 +172,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Du vil kanskje legge merke til _Claude Haiku 3.5_ i brukshistorikken din. Dette er en [lavprismodell](/docs/config/#models) som brukes til å generere titlene på øktene dine.
+1 -1
View File
@@ -172,7 +172,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Możesz zauważyć _Claude Haiku 3.5_ w historii użycia. To [niedrogi model](/docs/config/#models),
który służy do generowania tytułów Twoich sesji.
+1 -1
View File
@@ -161,7 +161,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Você pode notar _Claude Haiku 3.5_ no seu histórico de uso. Este é um [low cost model](/docs/config/#models) usado para gerar os títulos das suas sessões.
+1 -1
View File
@@ -172,7 +172,7 @@ https://opencode.ai/zen/v1/models
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
Вы можете заметить _Claude Haiku 3.5_ в истории использования. Это [недорогая модель](/docs/config/#models), которая используется для генерации заголовков ваших сессий.
+1 -1
View File
@@ -163,7 +163,7 @@ https://opencode.ai/zen/v1/models
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
คุณอาจสังเกตเห็น _Claude Haiku 3.5_ ในประวัติการใช้งานของคุณ นี่คือ [low cost model](/docs/config/#models) ที่ใช้สร้างชื่อ session ของคุณ

Some files were not shown because too many files have changed in this diff Show More