mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-12 11:54:29 +00:00
Compare commits
10 Commits
K-Mistele/
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa97475ee8 | ||
|
|
0eaeb4588e | ||
|
|
1413d77b1f | ||
|
|
624dd94b5d | ||
|
|
d86f24b6b3 | ||
|
|
03de51bd3c | ||
|
|
8f9742d988 | ||
|
|
f6e7aefa72 | ||
|
|
e269788a8f | ||
|
|
66780195dc |
54
.github/workflows/sign-cli.yml
vendored
Normal file
54
.github/workflows/sign-cli.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: sign-cli
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- brendan/desktop-signpath
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
sign-cli:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
|
||||
- name: Upload unsigned Windows CLI
|
||||
id: upload_unsigned_windows_cli
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unsigned-opencode-windows-cli
|
||||
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit SignPath signing request
|
||||
id: submit_signpath_signing_request
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_KEY }}
|
||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
||||
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: signed-opencode-cli
|
||||
|
||||
- name: Upload signed Windows CLI
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signed-opencode-windows-cli
|
||||
path: signed-opencode-cli/*.exe
|
||||
if-no-files-found: error
|
||||
7
.signpath/policies/opencode/test-signing.yml
Normal file
7
.signpath/policies/opencode/test-signing.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
github-policies:
|
||||
runners:
|
||||
allowed_groups:
|
||||
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
|
||||
build:
|
||||
disallow_reruns: false
|
||||
branch_rulesets:
|
||||
30
bun.lock
30
bun.lock
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -215,7 +215,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -244,7 +244,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -260,7 +260,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -366,7 +366,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -386,7 +386,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -397,7 +397,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -452,7 +452,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -463,7 +463,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -38,6 +38,7 @@ type RetryOptions = {
|
||||
excludeProviders: string[]
|
||||
retryCount: number
|
||||
}
|
||||
type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "balance"
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
@@ -51,6 +52,7 @@ export async function handler(
|
||||
type AuthInfo = Awaited<ReturnType<typeof authenticate>>
|
||||
type ModelInfo = Awaited<ReturnType<typeof validateModel>>
|
||||
type ProviderInfo = Awaited<ReturnType<typeof selectProvider>>
|
||||
type CostInfo = ReturnType<typeof calculateCost>
|
||||
|
||||
const MAX_FAILOVER_RETRIES = 3
|
||||
const MAX_429_RETRIES = 3
|
||||
@@ -139,21 +141,22 @@ export async function handler(
|
||||
"llm.error.code": res.status,
|
||||
"llm.error.message": res.statusText,
|
||||
})
|
||||
}
|
||||
|
||||
// Try another provider => stop retrying if using fallback provider
|
||||
if (
|
||||
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
|
||||
res.status !== 404 &&
|
||||
// ie. cannot change codex model providers mid-session
|
||||
modelInfo.stickyProvider !== "strict" &&
|
||||
modelInfo.fallbackProvider &&
|
||||
providerInfo.id !== modelInfo.fallbackProvider
|
||||
) {
|
||||
return retriableRequest({
|
||||
excludeProviders: [...retry.excludeProviders, providerInfo.id],
|
||||
retryCount: retry.retryCount + 1,
|
||||
})
|
||||
}
|
||||
// Try another provider => stop retrying if using fallback provider
|
||||
if (
|
||||
res.status !== 200 &&
|
||||
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
|
||||
res.status !== 404 &&
|
||||
// ie. cannot change codex model providers mid-session
|
||||
modelInfo.stickyProvider !== "strict" &&
|
||||
modelInfo.fallbackProvider &&
|
||||
providerInfo.id !== modelInfo.fallbackProvider
|
||||
) {
|
||||
return retriableRequest({
|
||||
excludeProviders: [...retry.excludeProviders, providerInfo.id],
|
||||
retryCount: retry.retryCount + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return { providerInfo, reqBody, res, startTimestamp }
|
||||
@@ -183,18 +186,25 @@ export async function handler(
|
||||
|
||||
// Handle non-streaming response
|
||||
if (!isStream) {
|
||||
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
|
||||
const json = await res.json()
|
||||
const body = JSON.stringify(responseConverter(json))
|
||||
const usageInfo = providerInfo.normalizeUsage(json.usage)
|
||||
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||
await trialLimiter?.track(usageInfo)
|
||||
await rateLimiter?.track()
|
||||
await trackUsage(billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||
await reload(billingSource, authInfo, costInfo)
|
||||
|
||||
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
|
||||
const body = JSON.stringify(
|
||||
responseConverter({
|
||||
...json,
|
||||
cost: calculateOccuredCost(billingSource, costInfo),
|
||||
}),
|
||||
)
|
||||
logger.metric({ response_length: body.length })
|
||||
logger.debug("RESPONSE: " + body)
|
||||
dataDumper?.provideResponse(body)
|
||||
dataDumper?.flush()
|
||||
const tokensInfo = providerInfo.normalizeUsage(json.usage)
|
||||
await trialLimiter?.track(tokensInfo)
|
||||
await rateLimiter?.track()
|
||||
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
|
||||
await reload(authInfo, costInfo)
|
||||
return new Response(body, {
|
||||
status: resStatus,
|
||||
statusText: res.statusText,
|
||||
@@ -226,12 +236,16 @@ export async function handler(
|
||||
dataDumper?.flush()
|
||||
await rateLimiter?.track()
|
||||
const usage = usageParser.retrieve()
|
||||
let cost = "0"
|
||||
if (usage) {
|
||||
const tokensInfo = providerInfo.normalizeUsage(usage)
|
||||
await trialLimiter?.track(tokensInfo)
|
||||
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
|
||||
await reload(authInfo, costInfo)
|
||||
const usageInfo = providerInfo.normalizeUsage(usage)
|
||||
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||
await trialLimiter?.track(usageInfo)
|
||||
await trackUsage(billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||
await reload(billingSource, authInfo, costInfo)
|
||||
cost = calculateOccuredCost(billingSource, costInfo)
|
||||
}
|
||||
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
|
||||
c.close()
|
||||
return
|
||||
}
|
||||
@@ -283,7 +297,6 @@ export async function handler(
|
||||
return pump()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
status: resStatus,
|
||||
statusText: res.statusText,
|
||||
@@ -498,9 +511,9 @@ export async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) {
|
||||
function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo): BillingSource {
|
||||
if (!authInfo) return "anonymous"
|
||||
if (authInfo.provider?.credentials) return "free"
|
||||
if (authInfo.provider?.credentials) return "byok"
|
||||
if (authInfo.isFree) return "free"
|
||||
if (modelInfo.allowAnonymous) return "free"
|
||||
|
||||
@@ -613,13 +626,7 @@ export async function handler(
|
||||
return res
|
||||
}
|
||||
|
||||
async function trackUsage(
|
||||
authInfo: AuthInfo,
|
||||
modelInfo: ModelInfo,
|
||||
providerInfo: ProviderInfo,
|
||||
billingSource: ReturnType<typeof validateBilling>,
|
||||
usageInfo: UsageInfo,
|
||||
) {
|
||||
function calculateCost(modelInfo: ModelInfo, usageInfo: UsageInfo) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
usageInfo
|
||||
|
||||
@@ -657,6 +664,33 @@ export async function handler(
|
||||
(cacheReadCost ?? 0) +
|
||||
(cacheWrite5mCost ?? 0) +
|
||||
(cacheWrite1hCost ?? 0)
|
||||
return {
|
||||
totalCostInCent,
|
||||
inputCost,
|
||||
outputCost,
|
||||
reasoningCost,
|
||||
cacheReadCost,
|
||||
cacheWrite5mCost,
|
||||
cacheWrite1hCost,
|
||||
}
|
||||
}
|
||||
|
||||
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
||||
}
|
||||
|
||||
async function trackUsage(
|
||||
billingSource: BillingSource,
|
||||
authInfo: AuthInfo,
|
||||
modelInfo: ModelInfo,
|
||||
providerInfo: ProviderInfo,
|
||||
usageInfo: UsageInfo,
|
||||
costInfo: CostInfo,
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
usageInfo
|
||||
const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } =
|
||||
costInfo
|
||||
|
||||
logger.metric({
|
||||
"tokens.input": inputTokens,
|
||||
@@ -677,7 +711,7 @@ export async function handler(
|
||||
if (billingSource === "anonymous") return
|
||||
authInfo = authInfo!
|
||||
|
||||
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||
const cost = centsToMicroCents(totalCostInCent)
|
||||
await Database.use((db) =>
|
||||
Promise.all([
|
||||
db.insert(UsageTable).values({
|
||||
@@ -772,16 +806,12 @@ export async function handler(
|
||||
return { costInMicroCents: cost }
|
||||
}
|
||||
|
||||
async function reload(authInfo: AuthInfo, costInfo: Awaited<ReturnType<typeof trackUsage>>) {
|
||||
if (!authInfo) return
|
||||
if (authInfo.isFree) return
|
||||
if (authInfo.provider?.credentials) return
|
||||
if (authInfo.subscription) return
|
||||
|
||||
if (!costInfo) return
|
||||
async function reload(billingSource: BillingSource, authInfo: AuthInfo, costInfo: CostInfo) {
|
||||
if (billingSource !== "balance") return
|
||||
authInfo = authInfo!
|
||||
|
||||
const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100)
|
||||
if (authInfo.billing.balance - costInfo.costInMicroCents >= reloadTrigger) return
|
||||
if (authInfo.billing.balance - costInfo.totalCostInCent >= reloadTrigger) return
|
||||
if (authInfo.billing.timeReloadLockedTill && authInfo.billing.timeReloadLockedTill > new Date()) return
|
||||
|
||||
const lock = await Database.use((tx) =>
|
||||
|
||||
@@ -167,6 +167,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
}
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
|
||||
@@ -56,6 +56,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
||||
usage = json.usageMetadata
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -54,6 +54,7 @@ export const oaCompatHelper: ProviderHelper = () => ({
|
||||
usage = json.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -43,6 +43,7 @@ export const openaiHelper: ProviderHelper = () => ({
|
||||
usage = json.response.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -43,6 +43,7 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
|
||||
createUsageParser: () => {
|
||||
parse: (chunk: string) => void
|
||||
retrieve: () => any
|
||||
buidlCostChunk: (cost: string) => string
|
||||
}
|
||||
normalizeUsage: (usage: any) => UsageInfo
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
13
packages/desktop/src-tauri/Cargo.lock
generated
13
packages/desktop/src-tauri/Cargo.lock
generated
@@ -3117,6 +3117,7 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
@@ -5631,6 +5632,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.17"
|
||||
|
||||
@@ -51,6 +51,7 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
chrono = "0.4"
|
||||
tokio-stream = { version = "0.1.18", features = ["sync"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
|
||||
@@ -1,35 +1,46 @@
|
||||
use futures::{FutureExt, Stream, StreamExt, future};
|
||||
use tauri::{AppHandle, Manager, path::BaseDirectory};
|
||||
use tauri_plugin_shell::{
|
||||
ShellExt,
|
||||
process::{Command, CommandChild, CommandEvent, TerminatedPayload},
|
||||
process::{CommandChild, CommandEvent, TerminatedPayload},
|
||||
};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tauri_specta::Event;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
|
||||
|
||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||
const CLI_BINARY_NAME: &str = "opencode";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct ServerConfig {
|
||||
pub hostname: Option<String>,
|
||||
pub port: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
pub server: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
pub async fn get_config(app: &AppHandle) -> Option<Config> {
|
||||
create_command(app, "debug config", &[])
|
||||
.output()
|
||||
let (events, _) = spawn_command(app, "debug config", &[]).ok()?;
|
||||
|
||||
events
|
||||
.fold(String::new(), async |mut config_str, event| {
|
||||
if let CommandEvent::Stdout(stdout) = event
|
||||
&& let Ok(s) = str::from_utf8(&stdout)
|
||||
{
|
||||
config_str += s
|
||||
}
|
||||
|
||||
config_str
|
||||
})
|
||||
.map(|v| serde_json::from_str::<Config>(&v))
|
||||
.await
|
||||
.inspect_err(|e| tracing::warn!("Failed to read OC config: {e}"))
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
|
||||
.and_then(|s| serde_json::from_str::<Config>(&s).ok())
|
||||
}
|
||||
|
||||
fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
||||
@@ -175,7 +186,11 @@ fn shell_escape(input: &str) -> String {
|
||||
escaped
|
||||
}
|
||||
|
||||
pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)]) -> Command {
|
||||
pub fn spawn_command(
|
||||
app: &tauri::AppHandle,
|
||||
args: &str,
|
||||
extra_env: &[(&str, String)],
|
||||
) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), tauri_plugin_shell::Error> {
|
||||
let state_dir = app
|
||||
.path()
|
||||
.resolve("", BaseDirectory::AppLocalData)
|
||||
@@ -202,7 +217,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St
|
||||
.map(|(key, value)| (key.to_string(), value.clone())),
|
||||
);
|
||||
|
||||
if cfg!(windows) {
|
||||
let cmd = if cfg!(windows) {
|
||||
if is_wsl_enabled(app) {
|
||||
tracing::info!("WSL is enabled, spawning CLI server in WSL");
|
||||
let version = app.package_info().version.to_string();
|
||||
@@ -234,10 +249,9 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St
|
||||
|
||||
script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
|
||||
|
||||
return app
|
||||
.shell()
|
||||
app.shell()
|
||||
.command("wsl")
|
||||
.args(["-e", "bash", "-lc", &script.join("\n")]);
|
||||
.args(["-e", "bash", "-lc", &script.join("\n")])
|
||||
} else {
|
||||
let mut cmd = app
|
||||
.shell()
|
||||
@@ -249,7 +263,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St
|
||||
cmd = cmd.env(key, value);
|
||||
}
|
||||
|
||||
return cmd;
|
||||
cmd
|
||||
}
|
||||
} else {
|
||||
let sidecar = get_sidecar_path(app);
|
||||
@@ -268,7 +282,13 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
};
|
||||
|
||||
let (rx, child) = cmd.spawn()?;
|
||||
let event_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream);
|
||||
|
||||
Ok((event_stream, child))
|
||||
}
|
||||
|
||||
pub fn serve(
|
||||
@@ -286,45 +306,96 @@ pub fn serve(
|
||||
("OPENCODE_SERVER_PASSWORD", password.to_string()),
|
||||
];
|
||||
|
||||
let (mut rx, child) = create_command(
|
||||
let (events, child) = spawn_command(
|
||||
app,
|
||||
format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(),
|
||||
&envs,
|
||||
)
|
||||
.spawn()
|
||||
.expect("Failed to spawn opencode");
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut exit_tx = Some(exit_tx);
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
tracing::info!(target: "sidecar", "{line}");
|
||||
}
|
||||
CommandEvent::Stderr(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
tracing::info!(target: "sidecar", "{line}");
|
||||
}
|
||||
CommandEvent::Error(err) => {
|
||||
tracing::error!(target: "sidecar", "{err}");
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
tracing::info!(
|
||||
target: "sidecar",
|
||||
code = ?payload.code,
|
||||
signal = ?payload.signal,
|
||||
"Sidecar terminated"
|
||||
);
|
||||
|
||||
if let Some(tx) = exit_tx.take() {
|
||||
let _ = tx.send(payload);
|
||||
let mut exit_tx = Some(exit_tx);
|
||||
tokio::spawn(
|
||||
events
|
||||
.for_each(move |event| {
|
||||
match event {
|
||||
CommandEvent::Stdout(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
tracing::info!("{line}");
|
||||
}
|
||||
CommandEvent::Stderr(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
tracing::info!("{line}");
|
||||
}
|
||||
CommandEvent::Error(err) => {
|
||||
tracing::error!("{err}");
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
tracing::info!(
|
||||
code = ?payload.code,
|
||||
signal = ?payload.signal,
|
||||
"Sidecar terminated"
|
||||
);
|
||||
|
||||
if let Some(tx) = exit_tx.take() {
|
||||
let _ = tx.send(payload);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
future::ready(())
|
||||
})
|
||||
.instrument(tracing::info_span!("sidecar")),
|
||||
);
|
||||
|
||||
(child, exit_rx)
|
||||
}
|
||||
|
||||
pub mod sqlite_migration {
|
||||
use super::*;
|
||||
|
||||
#[derive(
|
||||
tauri_specta::Event, serde::Serialize, serde::Deserialize, Clone, Copy, Debug, specta::Type,
|
||||
)]
|
||||
#[serde(tag = "type", content = "value")]
|
||||
pub enum SqliteMigrationProgress {
|
||||
InProgress(u8),
|
||||
Done,
|
||||
}
|
||||
|
||||
pub(super) fn logs_middleware(
|
||||
app: AppHandle,
|
||||
stream: impl Stream<Item = CommandEvent>,
|
||||
) -> impl Stream<Item = CommandEvent> {
|
||||
let app = app.clone();
|
||||
let mut done = false;
|
||||
|
||||
stream.filter_map(move |event| {
|
||||
if done {
|
||||
return future::ready(Some(event));
|
||||
}
|
||||
|
||||
future::ready(match &event {
|
||||
CommandEvent::Stdout(stdout) => {
|
||||
let Ok(s) = str::from_utf8(stdout) else {
|
||||
return future::ready(None);
|
||||
};
|
||||
|
||||
if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
|
||||
if let Ok(progress) = s.parse::<u8>() {
|
||||
let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
|
||||
} else if s == "done" {
|
||||
done = true;
|
||||
let _ = SqliteMigrationProgress::Done.emit(&app);
|
||||
}
|
||||
|
||||
None
|
||||
} else {
|
||||
Some(event)
|
||||
}
|
||||
}
|
||||
_ => Some(event),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,16 +24,17 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel};
|
||||
use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel};
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_shell::process::CommandChild;
|
||||
use tauri_specta::Event;
|
||||
use tokio::{
|
||||
sync::{oneshot, watch},
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
|
||||
use crate::cli::sync_cli;
|
||||
use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
|
||||
use crate::constants::*;
|
||||
use crate::server::get_saved_server_url;
|
||||
use crate::windows::{LoadingWindow, MainWindow};
|
||||
@@ -122,8 +123,8 @@ async fn await_initialization(
|
||||
let mut rx = init_state.current.clone();
|
||||
|
||||
let events = async {
|
||||
let e = (*rx.borrow()).clone();
|
||||
let _ = events.send(e).unwrap();
|
||||
let e = *rx.borrow();
|
||||
let _ = events.send(e);
|
||||
|
||||
while rx.changed().await.is_ok() {
|
||||
let step = *rx.borrow_and_update();
|
||||
@@ -517,7 +518,10 @@ fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
wsl_path,
|
||||
resolve_app_path
|
||||
])
|
||||
.events(tauri_specta::collect_events![LoadingWindowComplete])
|
||||
.events(tauri_specta::collect_events![
|
||||
LoadingWindowComplete,
|
||||
SqliteMigrationProgress
|
||||
])
|
||||
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
|
||||
}
|
||||
|
||||
@@ -556,17 +560,46 @@ async fn initialize(app: AppHandle) {
|
||||
|
||||
tracing::info!("Main and loading windows created");
|
||||
|
||||
// SQLite migration handling:
|
||||
// We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it
|
||||
// First, we spawn a task that listens for SqliteMigrationProgress events that can
|
||||
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
|
||||
// Then in the loading task, we wait for sqlite migration to complete before
|
||||
// starting our health check against the server, otherwise long migrations could result in a timeout.
|
||||
let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
|
||||
let sqlite_done = (sqlite_enabled && !sqlite_file_exists()).then(|| {
|
||||
tracing::info!(
|
||||
path = %opencode_db_path().expect("failed to get db path").display(),
|
||||
"Sqlite file not found, waiting for it to be generated"
|
||||
);
|
||||
|
||||
let (done_tx, done_rx) = oneshot::channel::<()>();
|
||||
let done_tx = Arc::new(Mutex::new(Some(done_tx)));
|
||||
|
||||
let init_tx = init_tx.clone();
|
||||
let id = SqliteMigrationProgress::listen(&app, move |e| {
|
||||
let _ = init_tx.send(InitStep::SqliteWaiting);
|
||||
|
||||
if matches!(e.payload, SqliteMigrationProgress::Done)
|
||||
&& let Some(done_tx) = done_tx.lock().unwrap().take()
|
||||
{
|
||||
let _ = done_tx.send(());
|
||||
}
|
||||
});
|
||||
|
||||
let app = app.clone();
|
||||
tokio::spawn(done_rx.map(async move |_| {
|
||||
app.unlisten(id);
|
||||
}))
|
||||
});
|
||||
|
||||
let loading_task = tokio::spawn({
|
||||
let init_tx = init_tx.clone();
|
||||
let app = app.clone();
|
||||
|
||||
async move {
|
||||
let mut sqlite_exists = sqlite_file_exists();
|
||||
|
||||
tracing::info!("Setting up server connection");
|
||||
let server_connection = setup_server_connection(app.clone()).await;
|
||||
tracing::info!("Server connection setup");
|
||||
|
||||
// we delay spawning this future so that the timeout is created lazily
|
||||
let cli_health_check = match server_connection {
|
||||
@@ -622,23 +655,12 @@ async fn initialize(app: AppHandle) {
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("server connection started");
|
||||
|
||||
if let Some(cli_health_check) = cli_health_check {
|
||||
if sqlite_enabled {
|
||||
tracing::debug!(sqlite_exists, "Checking sqlite file existence");
|
||||
if !sqlite_exists {
|
||||
tracing::info!(
|
||||
path = %opencode_db_path().expect("failed to get db path").display(),
|
||||
"Sqlite file not found, waiting for it to be generated"
|
||||
);
|
||||
let _ = init_tx.send(InitStep::SqliteWaiting);
|
||||
|
||||
while !sqlite_exists {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
sqlite_exists = sqlite_file_exists();
|
||||
}
|
||||
}
|
||||
if let Some(sqlite_done_rx) = sqlite_done {
|
||||
let _ = sqlite_done_rx.await;
|
||||
}
|
||||
|
||||
tokio::spawn(cli_health_check);
|
||||
}
|
||||
|
||||
@@ -654,11 +676,11 @@ async fn initialize(app: AppHandle) {
|
||||
.is_err()
|
||||
{
|
||||
tracing::debug!("Loading task timed out, showing loading window");
|
||||
let app = app.clone();
|
||||
let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window");
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
Some(loading_window)
|
||||
} else {
|
||||
tracing::debug!("Showing main window without loading window");
|
||||
MainWindow::create(&app).expect("Failed to create main window");
|
||||
|
||||
None
|
||||
@@ -667,7 +689,6 @@ async fn initialize(app: AppHandle) {
|
||||
let _ = loading_task.await;
|
||||
|
||||
tracing::info!("Loading done, completing initialisation");
|
||||
|
||||
let _ = init_tx.send(InitStep::Done);
|
||||
|
||||
if loading_window.is_some() {
|
||||
|
||||
@@ -11,17 +11,11 @@ use crate::{
|
||||
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY},
|
||||
};
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug)]
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug, Default)]
|
||||
pub struct WslConfig {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for WslConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const commands = {
|
||||
/** Events */
|
||||
export const events = {
|
||||
loadingWindowComplete: makeEvent<LoadingWindowComplete>("loading-window-complete"),
|
||||
sqliteMigrationProgress: makeEvent<SqliteMigrationProgress>("sqlite-migration-progress"),
|
||||
};
|
||||
|
||||
/* Types */
|
||||
@@ -37,6 +38,8 @@ export type ServerReadyData = {
|
||||
password: string | null,
|
||||
};
|
||||
|
||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };
|
||||
|
||||
export type WslConfig = {
|
||||
enabled: boolean,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import "@opencode-ai/app/index.css"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import "./styles.css"
|
||||
import { createSignal, Match, onMount } from "solid-js"
|
||||
import { createSignal, Match, onCleanup, onMount } from "solid-js"
|
||||
import { commands, events, InitStep } from "./bindings"
|
||||
import { Channel } from "@tauri-apps/api/core"
|
||||
import { Switch } from "solid-js"
|
||||
@@ -57,15 +57,29 @@ render(() => {
|
||||
"This could take a couple of minutes",
|
||||
]
|
||||
const [textIndex, setTextIndex] = createSignal(0)
|
||||
const [progress, setProgress] = createSignal(0)
|
||||
|
||||
onMount(async () => {
|
||||
const listener = events.sqliteMigrationProgress.listen((e) => {
|
||||
if (e.payload.type === "InProgress") setProgress(e.payload.value)
|
||||
})
|
||||
onCleanup(() => listener.then((c) => c()))
|
||||
|
||||
await new Promise((res) => setTimeout(res, 3000))
|
||||
setTextIndex(1)
|
||||
await new Promise((res) => setTimeout(res, 6000))
|
||||
setTextIndex(2)
|
||||
})
|
||||
|
||||
return <>{textItems[textIndex()]}</>
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span>{textItems[textIndex()]}</span>
|
||||
<span>Progress: {progress()}%</span>
|
||||
<div class="h-2 w-48 rounded-full border border-white relative">
|
||||
<div class="bg-[#fff] h-full absolute left-0 inset-y-0" style={{ width: `${progress()}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.59"
|
||||
version = "1.1.60"
|
||||
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.1.59/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.59/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.59/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/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.1.59/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/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.1.59/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Installation } from "@/installation"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
@@ -110,8 +111,17 @@ export function tui(input: {
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
return new Promise<void>(async (resolve) => {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const mode = await getTerminalBackgroundColor()
|
||||
|
||||
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
||||
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const onExit = async () => {
|
||||
unguard?.()
|
||||
await input.onExit?.()
|
||||
resolve()
|
||||
}
|
||||
@@ -730,7 +740,8 @@ function ErrorComponent(props: {
|
||||
const handleExit = async () => {
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
props.onExit()
|
||||
win32FlushInputBuffer()
|
||||
await props.onExit()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cmd } from "../cmd"
|
||||
import { tui } from "./app"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
@@ -26,27 +27,34 @@ export const AttachCommand = cmd({
|
||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
// If the directory doesn't exist locally (remote attach), pass it through.
|
||||
return args.dir
|
||||
}
|
||||
})()
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
await tui({
|
||||
url: args.url,
|
||||
args: { sessionID: args.session },
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
try {
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
// If the directory doesn't exist locally (remote attach), pass it through.
|
||||
return args.dir
|
||||
}
|
||||
})()
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
await tui({
|
||||
url: args.url,
|
||||
args: { sessionID: args.session },
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
} finally {
|
||||
unguard?.()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
message: {
|
||||
set: (value?: string) => () => void
|
||||
@@ -32,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await input.onExit?.()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Log } from "@/util/log"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@@ -77,99 +78,111 @@ export const TuiThreadCommand = cmd({
|
||||
describe: "agent to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
|
||||
const baseCwd = process.env.PWD ?? process.cwd()
|
||||
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
|
||||
const localWorker = new URL("./worker.ts", import.meta.url)
|
||||
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
|
||||
const workerPath = await iife(async () => {
|
||||
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
|
||||
if (await Bun.file(distWorker).exists()) return distWorker
|
||||
return localWorker
|
||||
})
|
||||
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
||||
// (Important when running under `bun run` wrappers on Windows.)
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
} catch (e) {
|
||||
UI.error("Failed to change directory to " + cwd)
|
||||
return
|
||||
// Must be the very first thing — disables CTRL_C_EVENT before any Worker
|
||||
// spawn or async work so the OS cannot kill the process group.
|
||||
win32DisableProcessedInput()
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
|
||||
const baseCwd = process.env.PWD ?? process.cwd()
|
||||
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
|
||||
const localWorker = new URL("./worker.ts", import.meta.url)
|
||||
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
|
||||
const workerPath = await iife(async () => {
|
||||
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
|
||||
if (await Bun.file(distWorker).exists()) return distWorker
|
||||
return localWorker
|
||||
})
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
} catch (e) {
|
||||
UI.error("Failed to change directory to " + cwd)
|
||||
return
|
||||
}
|
||||
|
||||
const worker = new Worker(workerPath, {
|
||||
env: Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
),
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error(e)
|
||||
}
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
process.on("uncaughtException", (e) => {
|
||||
Log.Default.error(e)
|
||||
})
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error(e)
|
||||
})
|
||||
process.on("SIGUSR2", async () => {
|
||||
await client.call("reload", undefined)
|
||||
})
|
||||
|
||||
const prompt = await iife(async () => {
|
||||
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
|
||||
if (!args.prompt) return piped
|
||||
return piped ? piped + "\n" + args.prompt : args.prompt
|
||||
})
|
||||
|
||||
// Check if server should be started (port or hostname explicitly set in CLI or config)
|
||||
const networkOpts = await resolveNetworkOptions(args)
|
||||
const shouldStartServer =
|
||||
process.argv.includes("--port") ||
|
||||
process.argv.includes("--hostname") ||
|
||||
process.argv.includes("--mdns") ||
|
||||
networkOpts.mdns ||
|
||||
networkOpts.port !== 0 ||
|
||||
networkOpts.hostname !== "127.0.0.1"
|
||||
|
||||
let url: string
|
||||
let customFetch: typeof fetch | undefined
|
||||
let events: EventSource | undefined
|
||||
|
||||
if (shouldStartServer) {
|
||||
// Start HTTP server for external access
|
||||
const server = await client.call("server", networkOpts)
|
||||
url = server.url
|
||||
} else {
|
||||
// Use direct RPC communication (no HTTP)
|
||||
url = "http://opencode.internal"
|
||||
customFetch = createWorkerFetch(client)
|
||||
events = createEventSource(client)
|
||||
}
|
||||
|
||||
const tuiPromise = tui({
|
||||
url,
|
||||
fetch: customFetch,
|
||||
events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
agent: args.agent,
|
||||
model: args.model,
|
||||
prompt,
|
||||
fork: args.fork,
|
||||
},
|
||||
onExit: async () => {
|
||||
await client.call("shutdown", undefined)
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
|
||||
}, 1000)
|
||||
|
||||
await tuiPromise
|
||||
} finally {
|
||||
unguard?.()
|
||||
}
|
||||
|
||||
const worker = new Worker(workerPath, {
|
||||
env: Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
),
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error(e)
|
||||
}
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
process.on("uncaughtException", (e) => {
|
||||
Log.Default.error(e)
|
||||
})
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error(e)
|
||||
})
|
||||
process.on("SIGUSR2", async () => {
|
||||
await client.call("reload", undefined)
|
||||
})
|
||||
|
||||
const prompt = await iife(async () => {
|
||||
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
|
||||
if (!args.prompt) return piped
|
||||
return piped ? piped + "\n" + args.prompt : args.prompt
|
||||
})
|
||||
|
||||
// Check if server should be started (port or hostname explicitly set in CLI or config)
|
||||
const networkOpts = await resolveNetworkOptions(args)
|
||||
const shouldStartServer =
|
||||
process.argv.includes("--port") ||
|
||||
process.argv.includes("--hostname") ||
|
||||
process.argv.includes("--mdns") ||
|
||||
networkOpts.mdns ||
|
||||
networkOpts.port !== 0 ||
|
||||
networkOpts.hostname !== "127.0.0.1"
|
||||
|
||||
let url: string
|
||||
let customFetch: typeof fetch | undefined
|
||||
let events: EventSource | undefined
|
||||
|
||||
if (shouldStartServer) {
|
||||
// Start HTTP server for external access
|
||||
const server = await client.call("server", networkOpts)
|
||||
url = server.url
|
||||
} else {
|
||||
// Use direct RPC communication (no HTTP)
|
||||
url = "http://opencode.internal"
|
||||
customFetch = createWorkerFetch(client)
|
||||
events = createEventSource(client)
|
||||
}
|
||||
|
||||
const tuiPromise = tui({
|
||||
url,
|
||||
fetch: customFetch,
|
||||
events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
agent: args.agent,
|
||||
model: args.model,
|
||||
prompt,
|
||||
fork: args.fork,
|
||||
},
|
||||
onExit: async () => {
|
||||
await client.call("shutdown", undefined)
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
|
||||
}, 1000)
|
||||
|
||||
await tuiPromise
|
||||
},
|
||||
})
|
||||
|
||||
129
packages/opencode/src/cli/cmd/tui/win32.ts
Normal file
129
packages/opencode/src/cli/cmd/tui/win32.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { dlopen, ptr } from "bun:ffi"
|
||||
|
||||
const STD_INPUT_HANDLE = -10
|
||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||
|
||||
const kernel = () =>
|
||||
dlopen("kernel32.dll", {
|
||||
GetStdHandle: { args: ["i32"], returns: "ptr" },
|
||||
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
|
||||
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
|
||||
FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
|
||||
})
|
||||
|
||||
let k32: ReturnType<typeof kernel> | undefined
|
||||
|
||||
function load() {
|
||||
if (process.platform !== "win32") return false
|
||||
try {
|
||||
k32 ??= kernel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
|
||||
*/
|
||||
export function win32DisableProcessedInput() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard any queued console input (mouse events, key presses, etc.).
|
||||
*/
|
||||
export function win32FlushInputBuffer() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
k32!.symbols.FlushConsoleInputBuffer(handle)
|
||||
}
|
||||
|
||||
let unhook: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Keep ENABLE_PROCESSED_INPUT disabled.
|
||||
*
|
||||
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
|
||||
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
|
||||
* (sometimes on a later tick), and the flag is console-global, not per-process.
|
||||
*
|
||||
* We combine:
|
||||
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
|
||||
* - A low-frequency poll as a backstop for native/external mode changes.
|
||||
*/
|
||||
export function win32InstallCtrlCGuard() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
if (unhook) return unhook
|
||||
|
||||
const stdin = process.stdin as any
|
||||
const original = stdin.setRawMode
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const initial = buf[0]!
|
||||
|
||||
const enforce = () => {
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
// Some runtimes can re-apply console modes on the next tick; enforce twice.
|
||||
const later = () => {
|
||||
enforce()
|
||||
setImmediate(enforce)
|
||||
}
|
||||
|
||||
let wrapped: ((mode: boolean) => unknown) | undefined
|
||||
|
||||
if (typeof original === "function") {
|
||||
wrapped = (mode: boolean) => {
|
||||
const result = original.call(stdin, mode)
|
||||
later()
|
||||
return result
|
||||
}
|
||||
|
||||
stdin.setRawMode = wrapped
|
||||
}
|
||||
|
||||
// Ensure it's cleared immediately too (covers any earlier mode changes).
|
||||
later()
|
||||
|
||||
const interval = setInterval(enforce, 100)
|
||||
interval.unref()
|
||||
|
||||
let done = false
|
||||
unhook = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
clearInterval(interval)
|
||||
if (wrapped && stdin.setRawMode === wrapped) {
|
||||
stdin.setRawMode = original
|
||||
}
|
||||
|
||||
k32!.symbols.SetConsoleMode(handle, initial)
|
||||
unhook = undefined
|
||||
}
|
||||
|
||||
return unhook
|
||||
}
|
||||
@@ -601,14 +601,20 @@ export namespace SessionPrompt {
|
||||
model,
|
||||
tools: lastUser.tools,
|
||||
processor,
|
||||
outputFormat: lastUser.format ?? { type: "text" },
|
||||
onStructuredOutputSuccess: (output) => {
|
||||
structuredOutput = output
|
||||
},
|
||||
bypassAgentCheck,
|
||||
messages: msgs,
|
||||
})
|
||||
|
||||
// Inject StructuredOutput tool if JSON schema mode enabled
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||
schema: lastUser.format.schema,
|
||||
onSuccess(output) {
|
||||
structuredOutput = output
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (step === 1) {
|
||||
SessionSummary.summarize({
|
||||
sessionID: sessionID,
|
||||
@@ -641,8 +647,8 @@ export namespace SessionPrompt {
|
||||
|
||||
// Build system prompt, adding structured output instruction if needed
|
||||
const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
|
||||
const outputFormat = lastUser.format ?? { type: "text" }
|
||||
if (outputFormat.type === "json_schema") {
|
||||
const format = lastUser.format ?? { type: "text" }
|
||||
if (format.type === "json_schema") {
|
||||
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||
}
|
||||
|
||||
@@ -665,7 +671,7 @@ export namespace SessionPrompt {
|
||||
],
|
||||
tools,
|
||||
model,
|
||||
toolChoice: outputFormat.type === "json_schema" ? "required" : undefined,
|
||||
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
||||
})
|
||||
|
||||
// If structured output was captured, save it and exit immediately
|
||||
@@ -681,7 +687,7 @@ export namespace SessionPrompt {
|
||||
const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
|
||||
|
||||
if (modelFinished && !processor.message.error) {
|
||||
if (outputFormat.type === "json_schema") {
|
||||
if (format.type === "json_schema") {
|
||||
// Model stopped without calling StructuredOutput tool
|
||||
processor.message.error = new MessageV2.StructuredOutputError({
|
||||
message: "Model did not produce structured output",
|
||||
@@ -729,8 +735,6 @@ export namespace SessionPrompt {
|
||||
session: Session.Info
|
||||
tools?: Record<string, boolean>
|
||||
processor: SessionProcessor.Info
|
||||
outputFormat: MessageV2.OutputFormat
|
||||
onStructuredOutputSuccess?: (output: unknown) => void
|
||||
bypassAgentCheck: boolean
|
||||
messages: MessageV2.WithParts[]
|
||||
}) {
|
||||
@@ -902,14 +906,6 @@ export namespace SessionPrompt {
|
||||
tools[key] = item
|
||||
}
|
||||
|
||||
// Inject StructuredOutput tool if JSON schema mode enabled
|
||||
if (input.outputFormat.type === "json_schema" && input.onStructuredOutputSuccess) {
|
||||
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||
schema: input.outputFormat.schema,
|
||||
onSuccess: input.onStructuredOutputSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const EditTool = Tool.define("edit", {
|
||||
}
|
||||
|
||||
if (params.oldString === params.newString) {
|
||||
throw new Error("oldString and newString must be different")
|
||||
throw new Error("No changes to apply: oldString and newString are identical.")
|
||||
}
|
||||
|
||||
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
@@ -617,7 +617,7 @@ export function trimDiff(diff: string): string {
|
||||
|
||||
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
|
||||
if (oldString === newString) {
|
||||
throw new Error("oldString and newString must be different")
|
||||
throw new Error("No changes to apply: oldString and newString are identical.")
|
||||
}
|
||||
|
||||
let notFound = true
|
||||
@@ -647,9 +647,9 @@ export function replace(content: string, oldString: string, newString: string, r
|
||||
}
|
||||
|
||||
if (notFound) {
|
||||
throw new Error("oldString not found in content")
|
||||
throw new Error(
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
"Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.",
|
||||
)
|
||||
throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@ export const GlobTool = Tool.define("glob", {
|
||||
output.push(...files.map((f) => f.path))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||
output.push(
|
||||
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,8 @@ export const GrepTool = Tool.define("grep", {
|
||||
}
|
||||
}
|
||||
|
||||
const outputLines = [`Found ${finalMatches.length} matches`]
|
||||
const totalMatches = matches.length
|
||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
let currentFile = ""
|
||||
for (const match of finalMatches) {
|
||||
@@ -127,7 +128,9 @@ export const GrepTool = Tool.define("grep", {
|
||||
|
||||
if (truncated) {
|
||||
outputLines.push("")
|
||||
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||
outputLines.push(
|
||||
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
@@ -138,7 +141,7 @@ export const GrepTool = Tool.define("grep", {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
matches: finalMatches.length,
|
||||
matches: totalMatches,
|
||||
truncated,
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2420,6 +2420,9 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/components/schemas/OutputFormat"
|
||||
},
|
||||
"system": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2796,6 +2799,9 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/components/schemas/OutputFormat"
|
||||
},
|
||||
"system": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3790,9 +3796,12 @@
|
||||
"properties": {
|
||||
"npm": {
|
||||
"type": "string"
|
||||
},
|
||||
"api": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["npm"]
|
||||
"required": ["npm", "api"]
|
||||
},
|
||||
"variants": {
|
||||
"type": "object",
|
||||
@@ -6070,6 +6079,52 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"OutputFormatText": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "text"
|
||||
}
|
||||
},
|
||||
"required": ["type"]
|
||||
},
|
||||
"JSONSchema": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"OutputFormatJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "json_schema"
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/JSONSchema"
|
||||
},
|
||||
"retryCount": {
|
||||
"default": 2,
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": ["type", "schema"]
|
||||
},
|
||||
"OutputFormat": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/OutputFormatText"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OutputFormatJsonSchema"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6117,6 +6172,9 @@
|
||||
},
|
||||
"required": ["created"]
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/components/schemas/OutputFormat"
|
||||
},
|
||||
"summary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6242,6 +6300,28 @@
|
||||
},
|
||||
"required": ["name", "data"]
|
||||
},
|
||||
"StructuredOutputError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"const": "StructuredOutputError"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"retries": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["message", "retries"]
|
||||
}
|
||||
},
|
||||
"required": ["name", "data"]
|
||||
},
|
||||
"ContextOverflowError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6349,6 +6429,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageAbortedError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/StructuredOutputError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ContextOverflowError"
|
||||
},
|
||||
@@ -6420,6 +6503,7 @@
|
||||
},
|
||||
"required": ["input", "output", "reasoning", "cache"]
|
||||
},
|
||||
"structured": {},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -8125,6 +8209,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageAbortedError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/StructuredOutputError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ContextOverflowError"
|
||||
},
|
||||
@@ -9314,9 +9401,12 @@
|
||||
"properties": {
|
||||
"npm": {
|
||||
"type": "string"
|
||||
},
|
||||
"api": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["npm"]
|
||||
"required": ["npm", "api"]
|
||||
},
|
||||
"variants": {
|
||||
"description": "Variant-specific configuration",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -127,24 +127,24 @@ You can request structured JSON output from the model by specifying an `outputFo
|
||||
const result = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
parts: [{ type: 'text', text: 'Research Anthropic and provide company info' }],
|
||||
parts: [{ type: "text", text: "Research Anthropic and provide company info" }],
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
company: { type: 'string', description: 'Company name' },
|
||||
founded: { type: 'number', description: 'Year founded' },
|
||||
company: { type: "string", description: "Company name" },
|
||||
founded: { type: "number", description: "Year founded" },
|
||||
products: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Main products'
|
||||
}
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Main products",
|
||||
},
|
||||
},
|
||||
required: ['company', 'founded']
|
||||
}
|
||||
}
|
||||
}
|
||||
required: ["company", "founded"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Access the structured output
|
||||
@@ -154,29 +154,29 @@ console.log(result.data.info.structured_output)
|
||||
|
||||
### Output Format Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `text` | Default. Standard text response (no structured output) |
|
||||
| `json_schema` | Returns validated JSON matching the provided schema |
|
||||
| Type | Description |
|
||||
| ------------- | ------------------------------------------------------ |
|
||||
| `text` | Default. Standard text response (no structured output) |
|
||||
| `json_schema` | Returns validated JSON matching the provided schema |
|
||||
|
||||
### JSON Schema Format
|
||||
|
||||
When using `type: 'json_schema'`, provide:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | `'json_schema'` | Required. Specifies JSON schema mode |
|
||||
| `schema` | `object` | Required. JSON Schema object defining the output structure |
|
||||
| `retryCount` | `number` | Optional. Number of validation retries (default: 2) |
|
||||
| Field | Type | Description |
|
||||
| ------------ | --------------- | ---------------------------------------------------------- |
|
||||
| `type` | `'json_schema'` | Required. Specifies JSON schema mode |
|
||||
| `schema` | `object` | Required. JSON Schema object defining the output structure |
|
||||
| `retryCount` | `number` | Optional. Number of validation retries (default: 2) |
|
||||
|
||||
### Error Handling
|
||||
|
||||
If the model fails to produce valid structured output after all retries, the response will include a `StructuredOutputError`:
|
||||
|
||||
```typescript
|
||||
if (result.data.info.error?.name === 'StructuredOutputError') {
|
||||
console.error('Failed to produce structured output:', result.data.info.error.message)
|
||||
console.error('Attempts:', result.data.info.error.retries)
|
||||
if (result.data.info.error?.name === "StructuredOutputError") {
|
||||
console.error("Failed to produce structured output:", result.data.info.error.message)
|
||||
console.error("Attempts:", result.data.info.error.retries)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -298,27 +298,27 @@ const { providers, default: defaults } = await client.config.providers()
|
||||
|
||||
### Sessions
|
||||
|
||||
| Method | Description | Notes |
|
||||
| ---------------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `session.list()` | List sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
|
||||
| `session.get({ path })` | Get session | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.children({ path })` | List child sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
|
||||
| `session.create({ body })` | Create session | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.delete({ path })` | Delete session | Returns `boolean` |
|
||||
| `session.update({ path, body })` | Update session properties | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.init({ path, body })` | Analyze app and create `AGENTS.md` | Returns `boolean` |
|
||||
| `session.abort({ path })` | Abort a running session | Returns `boolean` |
|
||||
| `session.share({ path })` | Share session | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.unshare({ path })` | Unshare session | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.summarize({ path, body })` | Summarize session | Returns `boolean` |
|
||||
| `session.messages({ path })` | List messages in a session | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}[]` |
|
||||
| `session.message({ path })` | Get message details | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
|
||||
| Method | Description | Notes |
|
||||
| ---------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `session.list()` | List sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
|
||||
| `session.get({ path })` | Get session | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.children({ path })` | List child sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
|
||||
| `session.create({ body })` | Create session | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.delete({ path })` | Delete session | Returns `boolean` |
|
||||
| `session.update({ path, body })` | Update session properties | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.init({ path, body })` | Analyze app and create `AGENTS.md` | Returns `boolean` |
|
||||
| `session.abort({ path })` | Abort a running session | Returns `boolean` |
|
||||
| `session.share({ path })` | Share session | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.unshare({ path })` | Unshare session | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.summarize({ path, body })` | Summarize session | Returns `boolean` |
|
||||
| `session.messages({ path })` | List messages in a session | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}[]` |
|
||||
| `session.message({ path })` | Get message details | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
|
||||
| `session.prompt({ path, body })` | Send prompt message | `body.noReply: true` returns UserMessage (context only). Default returns <a href={typesUrl}><code>AssistantMessage</code></a> with AI response. Supports `body.outputFormat` for [structured output](#structured-output) |
|
||||
| `session.command({ path, body })` | Send command to session | Returns `{ info: `<a href={typesUrl}><code>AssistantMessage</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
|
||||
| `session.shell({ path, body })` | Run a shell command | Returns <a href={typesUrl}><code>AssistantMessage</code></a> |
|
||||
| `session.revert({ path, body })` | Revert a message | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.unrevert({ path })` | Restore reverted messages | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `postSessionByIdPermissionsByPermissionId({ path, body })` | Respond to a permission request | Returns `boolean` |
|
||||
| `session.command({ path, body })` | Send command to session | Returns `{ info: `<a href={typesUrl}><code>AssistantMessage</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
|
||||
| `session.shell({ path, body })` | Run a shell command | Returns <a href={typesUrl}><code>AssistantMessage</code></a> |
|
||||
| `session.revert({ path, body })` | Revert a message | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `session.unrevert({ path })` | Restore reverted messages | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||
| `postSessionByIdPermissionsByPermissionId({ path, body })` | Respond to a permission request | Returns `boolean` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.1.59",
|
||||
"version": "1.1.60",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user