Compare commits

...
Author SHA1 Message Date
Kit Langton 2bc9f6ea09 refactor(plugin): isolate effect authoring runtime 2026-08-24 13:44:14 -04:00
Dax Raad aa8c1f6dac fix(tui): scope MCP dialog to location 2026-08-24 12:17:12 -04:00
Kit Langton 23c3a1461c fix(merman): harden responsive diagram layouts (#44714) 2026-08-24 12:09:00 -04:00
opencode-agent[bot] 50a8539e4b chore: update nix node_modules hashes 2026-08-24 14:29:37 +00:00
Shoubhit Dash 7b47589225 refactor(core): optimize stats tool summary (#44690) 2026-08-24 19:39:04 +05:30
OpeOginniandShoubhit Dash 84275c6e9d fix(cli): restore print-logs flag for --standalone runs (#44112)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-08-24 19:32:38 +05:30
Kit Langton 793ea52fa7 fix(core): make packed SDK boot on workerd (#44703) 2026-08-24 14:02:26 +00:00
Simon Klee 40380ad9b5 tui: update OpenTUI to 0.5.8 (#44698) 2026-08-24 15:42:27 +02:00
Kit Langton 156cb7dd10 feat(tui): render Mermaid Gantt diagrams (#44534) 2026-08-24 08:50:32 -04:00
Shoubhit Dash 0a60910208 fix(cli): preserve stats server errors (#44685) 2026-08-24 18:11:46 +05:30
Shoubhit Dash 11e2bde399 fix(stats): correct aggregation boundaries (#44682) 2026-08-24 17:26:43 +05:30
Simon Klee ea85118516 tui: preserve transcript window while scrolling (#44679) 2026-08-24 13:30:31 +02:00
Shoubhit Dash 2e1d7c84ab feat(cli): add shareable stats command (#43653) 2026-08-24 16:47:23 +05:30
opencode-agent[bot]andnexxeln 3b8949b1ee fix(tui): load paginated session history (#44656)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-24 16:02:19 +05:30
Luke Parker 97daae9b77 feat(app): align context search rows with figma (#44654) 2026-08-24 20:14:40 +10:00
opencode-agent[bot] 3dd84d629d fix(session-ui): preserve timeline message order (#44652) 2026-08-24 09:44:47 +00:00
Luke Parker 279062d3c9 feat(app): align execute tool with shell treatment (#44642) 2026-08-24 09:20:56 +00:00
Brendan Allan 0a410e404b fix(session-ui): show tool errors inline (#44632) 2026-08-24 07:59:12 +00:00
opencode-agent[bot]andBrendonovich 5cd40ad66f fix(app): remember submitted workspace choice (#44620)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-24 07:12:46 +00:00
Luke Parker 46d1f1fed1 refactor(app): make auto-accept permissions an app-level setting (#44608) 2026-08-24 16:56:14 +10:00
opencode-agent[bot]andBrendonovich e282066cf8 fix(app): place thinking above queued prompts (#44616)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 06:05:22 +00:00
opencode-agent[bot]andBrendonovich 6704f34cad fix(app): handle Windows menu accelerators (#44607)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 13:57:52 +08:00
209 changed files with 19735 additions and 5159 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Compile Effect-authored plugins to the runtime-neutral Promise and Standard Schema plugin contract.
+6
View File
@@ -89,6 +89,12 @@ jobs:
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
timeout-minutes: 15
working-directory: packages/sdk
run: bun run verify:package
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
+19 -17
View File
@@ -579,7 +579,6 @@
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -590,19 +589,22 @@
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.7",
"@opentui/solid": ">=0.5.7",
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"effect": "catalog:",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"@opencode-ai/theme",
"@opentui/core",
"@opentui/solid",
"effect",
"solid-js",
],
},
@@ -1090,9 +1092,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.7",
"@opentui/keymap": "0.5.7",
"@opentui/solid": "0.5.7",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2218,27 +2220,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.7", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.7", "@opentui/core-darwin-x64": "0.5.7", "@opentui/core-linux-arm64": "0.5.7", "@opentui/core-linux-arm64-musl": "0.5.7", "@opentui/core-linux-x64": "0.5.7", "@opentui/core-linux-x64-musl": "0.5.7", "@opentui/core-win32-arm64": "0.5.7", "@opentui/core-win32-x64": "0.5.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-/XDabTkfBs2Wy2FhlC4jvzbpphYAs4SlnCRKYEvi+metlXNTSAshSs43wqZ9O4IPd0E4EcQqdfeQ0sdc3yPpYw=="],
"@opentui/core": ["@opentui/core@0.5.8", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.8", "@opentui/core-darwin-x64": "0.5.8", "@opentui/core-linux-arm64": "0.5.8", "@opentui/core-linux-arm64-musl": "0.5.8", "@opentui/core-linux-x64": "0.5.8", "@opentui/core-linux-x64-musl": "0.5.8", "@opentui/core-win32-arm64": "0.5.8", "@opentui/core-win32-x64": "0.5.8" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-GbZ+nSLYZqxj2Z5TU19Mx6IsAvVsn2+7WEXz+6OlMGoootvt3TxP3vWwfGI0jWk9qp1ftRlML1JPNEkzy+9I8g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-75TDJgFD6hDoCElIX35Yg3TIRF/jhrtVQO/9snhjGuTsZ7bd0W88jUlvSXSNhqB3CX431rwgi47B+asWPDI1lQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c9Y1FBrSnA4sKUCMETsrLYOmsMTyJae8mU9cE6M4o9rXcr3ZLPA7o9AkPA3S0+kH+kZ0o1Fn0wLejgNxvEp5mg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-j+Dwu2yV8zahBFjnVIssYdPrHz/+pkd7xWxI+nHjV41V3c+scvH6vJY1U85VCVaoQs/zCH5xAJbmAAn7Sgeh8g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-oZ/6Iz1KN+4volMFKmmvziYJhMgyyJ99LfK1S+uPRyIRqzT3CESoJ58D4h04M1g0dHeHE4PvkU1p3uQKrXiP3g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWO9NWaivXRPc0XhEbxehj3ApyC1SW8Bf1cnHnME1zhw/EZNCNE3TQHgKwOnnNKtvlbhzLQjOI4OqyPYV8UcEQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-N6i/ocrsTjIq9aUQyrfJkqUo+tc4P5ZS6xz38Cm1MhtDzwBE+CrNIJdHfoJfmRLhJkYHlw6Z0ToICn8ZNj4Bpw=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RumSHTasIAWU7jPBKiTuEZG/m+prfQKCHhL3JAQnBLgp7eAB20XZGfSLVWDBgxkyKq1rHCUgLtZktC5ZAyKfTA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-eFMB41AWODaYf8PsCx3vtTMX33tFgtSpR9tKNnUErCXlcnZ+WCRHVaJ9F6DrVNX3ir6HmgbwAmaWXvaVF+kLtg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-fXDmrIlfp9xaoVkk3BNn3yUO/b7plBSOfUh2oWOezRA6E/g5z+hTO1alGFQCy+VV+1kfA6iuYadAww0xNtfACg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-/2QM7/wMnML/sxchzbwgoU5tUu/7k836/kSOKMti8opjuecv1K+WWNKGXufhTNeRcXFZaba5rsCdYrf3VqnVsQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-1opDV+W7C1F4iWivCNKuYl5Hen+IeFXAuUPm3hVyN0UtdcP8LESpFJTr8QRwHpGSE9Jz1K2g2S2ipzI3u5mhgw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YXo+qUHYmep2uvv3ECvTeqr10aD7+lBsavYmsTLBzS5hHabbzlQ10oX/99nIRPC3Au1BMWD6d6zQP5czPx23eg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-gbiZUyttjq8s+bsnsaopygWN7LTS/RyzD6GOmpgOB2TVitvo0P6mxM+AebBGuu/7xri17Rqaxf3uqrgdKiJAeg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-7qBdhEAlh4tLFzW7nWLPlREtNiF6NZMDMi+4uDpUlAMhRavLr6wjcIcgfhNAF/puq06DjuZlPTwaMCgB3qOuwA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-bDwon45lUxbV3rMcekTCg4mXcBu9uhdG6HLeLK5TVZf05/h+Ibkf2UeZ8A2jjQt+MK6jpYmxWwDCVCTzZ7EU7Q=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Z76YaTKnmRDSHKdKa7iTBCXBQdInQLq7UG3qIE84nvyHCDtkhYtPWOaCzDisSgXC8Y6hJt6NiFbaNjQyxPkfpQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.7", "", { "dependencies": { "@opentui/core": "0.5.7" }, "peerDependencies": { "@opentui/react": "0.5.7", "@opentui/solid": "0.5.7", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BqfSbjlLuctnew2aoPPdK3wpzJfEPp7ttLBkTu1wJTkps9AcfUgXqwloqvY4DUKjUBNFYYEpU/0CPxI4Blj4MA=="],
"@opentui/keymap": ["@opentui/keymap@0.5.8", "", { "dependencies": { "@opentui/core": "0.5.8" }, "peerDependencies": { "@opentui/react": "0.5.8", "@opentui/solid": "0.5.8", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-KQHKRnLZroSZIbHmGmSeDPsXi7Yykdu9x9ACmDzBFkgPGx0EIuXHqRcmrqmUaX485MQEZuCD9t6FdDsOMGyIFg=="],
"@opentui/solid": ["@opentui/solid@0.5.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qrKAZd9xt4D67LXZUARg1Aw0dwEWi29GyOxpM6abqmyExGPMS7jih5Z9oMQJ9EXsouDKpW7Mejjq7WbX2AaecQ=="],
"@opentui/solid": ["@opentui/solid@0.5.8", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.8", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-L0NxuAU8XT+jlE5G90oA3kspqkof48b0hmzi5XLw+1gxnkxrkTb+YfKys+GzVK4UqhgwY9aW+TeDfrKbnfwCMw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
"x86_64-linux": "sha256-phyTF0/jQZ3L0B66PSLdpH//kyPc1M6j5a40wCSx7TA=",
"aarch64-linux": "sha256-1Zb/Is0ujIslCbPPusAVhcuzAPyIauQyeIIRRGtzpAk=",
"aarch64-darwin": "sha256-DDsVm7z+PSDry6QqrwVDFSmEnq6jIKb709Y4ymAv9f8=",
"x86_64-darwin": "sha256-S+5LI2J+WRhRP7jp2PAv6AesXk238wEYoyIO1oKdF3w="
}
}
+3 -3
View File
@@ -49,9 +49,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.7",
"@opentui/keymap": "0.5.7",
"@opentui/solid": "0.5.7",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -13,9 +13,14 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, permissionRequests)
// Server A has no tab and is never visited: a pending request there proves
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
})
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -38,7 +43,17 @@ test("session settings use the remote server context", async ({ page }) => {
}),
)
.toBe(true)
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { reply: "once" },
},
])
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
@@ -143,6 +158,99 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
])
})
test("auto-accept sweeps again after a reconnect", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const pendingA: MockPermission[] = []
const listFailures: Record<string, number> = {}
const sessionGets: string[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport(page, { server: serverA, retry: 20 })
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: pendingA },
listFailures,
sessionGets,
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
const first = await transport.waitForConnection()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
// This request is asked while the client is disconnected, so it is never
// delivered as an event and only a reconnect sweep can find it. The first
// listing after the reconnect fails, so only the bounded sweep retry can
// deliver the reply.
pendingA.push(pendingPermission("permission-offline-a", sessionA.id))
listFailures[serverA] = 1
const syncsBeforeReconnect = sessionGets.length
await transport.disconnect()
await transport.waitForConnection({ after: first.id })
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { reply: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
// cached locations, since another client may have moved them meanwhile.
expect(sessionGets.slice(syncsBeforeReconnect)).toContain(sessionA.id)
})
test("auto-accept approves a request discovered by opening a session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
// The request is only served from the per-session permission list, so it
// reaches the client through the store sync when the session view opens,
// never through a location sweep or an event.
await mockServers(page, permissionRequests, permissionResponses, {
sessionPending: { [sessionA.id]: [pendingPermission("permission-synced-a", sessionA.id)] },
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { reply: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
@@ -151,6 +259,19 @@ type PermissionResponse = {
body: unknown
}
type MockPermission = {
id: string
sessionID: string
action: string
resources: string[]
metadata: Record<string, unknown>
save: unknown[]
}
function pendingPermission(id: string, sessionID: string): MockPermission {
return { id, sessionID, action: "shell", resources: ["git status"], metadata: {}, save: [] }
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
@@ -161,7 +282,23 @@ async function configureServers(page: Page, tabs: { type: "session"; server: str
)
}
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
type MockServerOptions = {
// Pending requests served from /api/permission/request, keyed by origin.
pending?: Record<string, MockPermission[]>
// Pending requests served from /api/session/:id/permission, keyed by session ID.
sessionPending?: Record<string, MockPermission[]>
// Counts of /api/permission/request calls to fail with a 500, keyed by origin.
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
}
async function mockServers(
page: Page,
permissionRequests: string[],
permissionResponses: PermissionResponse[] = [],
options: MockServerOptions = {},
) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@@ -178,8 +315,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
return json(route, true)
// The generated client requires exactly 204 for a successful reply.
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionPermission = url.pathname.match(/^\/api\/session\/([^/]+)\/permission$/)
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/provider")
return json(route, {
@@ -197,7 +338,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/permission/request") {
permissionRequests.push(url.toString())
return json(route, { location: { directory }, data: [] })
const failures = options.listFailures?.[url.origin] ?? 0
if (failures > 0) {
options.listFailures![url.origin] = failures - 1
return json(route, { name: "Internal" }, 500)
}
return json(route, { location: { directory }, data: options.pending?.[url.origin] ?? [] })
}
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
@@ -219,9 +365,13 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === "/api/session/active")
return json(route, { data: Object.fromEntries(sessions.map((session) => [session.id, { type: "running" }])) })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (currentSessionInfo) {
options.sessionGets?.push(currentSessionInfo.id)
return json(route, { data: currentSession(currentSessionInfo) })
}
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
@@ -373,7 +373,6 @@ export function HomeSessionStatusController(props: {
}) {
const avatar = useSessionTabAvatarState(
() => props.server,
() => props.record.session.location.directory,
() => props.record.session.id,
() => true,
)
@@ -8,7 +8,6 @@ import { createComposerControls, createComposerModelSelection } from "@/composer
import { createComposerProjectControls } from "./project/controller"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
import { usePermission } from "@/session/requests/permission"
import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
@@ -30,7 +29,6 @@ export function createNewSessionComposerAdapter(props: {
const data = useData()
const server = useServer()
const serverSDK = useServerSDK()
const permission = usePermission()
const tabs = useTabs()
const location = useWorkspaceLocation()
const language = useLanguage()
@@ -86,9 +84,6 @@ export function createNewSessionComposerAdapter(props: {
)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
permission.enableAutoAccept(created.id, sessionDirectory)
}
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,
@@ -18,13 +18,23 @@ describe("new session workspace selection", () => {
).toBe("main")
})
test("derives an existing worktree from the current directory", () => {
test("uses the saved destination instead of the current worktree", () => {
expect(
resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }),
).toBe("/project/feature")
expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe(
"main",
)
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "create",
}),
).toBe("create")
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "main",
}),
).toBe("/project")
})
test("normalizes main to the project root outside the main worktree", () => {
@@ -33,9 +43,9 @@ describe("new session workspace selection", () => {
})
test("treats equivalent Windows roots as the main worktree", () => {
expect(resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" })).toBe(
"main",
)
expect(
resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" }),
).toBe("main")
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
})
@@ -21,8 +21,7 @@ export function resolveNewSessionWorktree(input: {
}) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
if (input.projectWorktree && !sameDirectory(input.directory, input.projectWorktree)) return input.directory
return input.fallback ?? "main"
return normalizeNewSessionWorktree(input.fallback ?? "main", input.directory, input.projectWorktree)
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
@@ -121,7 +120,6 @@ export function createNewSessionWorkspaceController(input: {
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
remember(worktree)
},
},
project: {
+2 -3
View File
@@ -8,7 +8,7 @@ import { createServerSdkContext } from "./client"
import { createServerSyncContext } from "./sync"
import { createData } from "@opencode-ai/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createServerPermissionState } from "@/session/requests/server-permission"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
@@ -144,7 +144,7 @@ function createServerController(
directory: "",
})
const sync = createServerSyncContext(sdk, data)
const permission = createServerPermissionState({ sdk, sync, data })
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) {
@@ -187,7 +187,6 @@ function createServerController(
list: projectsList,
recentlyClosed: recentlyClosedList,
},
permission,
notification,
}
}
@@ -4,9 +4,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePermission } from "@/session/requests/permission"
import { useComposerState } from "@/composer/persistence"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { useTerminal } from "@/session/terminal/context"
@@ -48,9 +46,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const permission = usePermission()
const prompt = useComposerState()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const settings = useSettings()
const terminal = useTerminal()
@@ -99,11 +95,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const mcpCommand = withCategory(language.t("command.category.mcp"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))
const isAutoAcceptActive = () => {
const sessionID = actions.session.identity.params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
return permission.isAutoAcceptingDirectory(sdk().directory)
}
const exportSession = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
@@ -223,13 +214,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const toggleAutoAccept = () => {
const sessionID = actions.session.identity.params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
else permission.toggleAutoAcceptDirectory(sdk().directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk().directory)
: permission.isAutoAcceptingDirectory(sdk().directory)
const active = !settings.permissions.autoApprove()
settings.permissions.setAutoApprove(active)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
@@ -454,7 +440,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permissionsCmds = () => [
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
title: settings.permissions.autoApprove()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
@@ -0,0 +1,136 @@
import { createEffect, onCleanup } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import type { ServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
const respondedLimit = 1000
const retryLimit = 2
const retryDelayMs = 1000
// Auto-approves permission requests on one server connection whenever the
// app-level auto-approve setting is on. The setting lives in the client-local
// settings store, so it applies to every session, tab, and server at once.
export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data }) {
const enabled = useSettings().permissions.autoApprove
const state = { disposed: false, generation: 0, responded: new Set<string>() }
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (enabled()) approve(event.data)
})
onCleanup(() => {
state.disposed = true
unsubscribe()
})
// The event stream does not replay requests asked while this client was
// disconnected, and requests may already be pending before the setting turns
// on, so sweep on every connect while the setting is on.
createEffect(() => {
if (!enabled() || input.sdk.connection.status() !== "connected") return
const generation = ++state.generation
void sweepWithRetry(generation, 0)
})
// Approves pending requests that reach the local store, which is how a
// previously unknown idle session's requests surface when its view opens
// and syncs them. Store changes cannot re-trigger the network sweep: it
// deliberately reads them after an await, outside Solid tracking.
createEffect(() => {
if (!enabled()) return
for (const session of input.data.session.list()) {
for (const request of input.data.session.permission.list(session.id) ?? []) approve(request)
}
})
// An incomplete sweep leaves pending requests hidden with no later trigger
// to recover them, so retry it a bounded number of times. A newer sweep
// supersedes scheduled retries.
async function sweepWithRetry(generation: number, attempt: number) {
const complete = await sweep()
if (complete || attempt >= retryLimit) return
setTimeout(() => {
if (state.disposed || !enabled() || generation !== state.generation) return
void sweepWithRetry(generation, attempt + 1)
}, retryDelayMs * (attempt + 1))
}
async function sweep() {
const inventory = await sweepLocations()
const listed = await Promise.all(
inventory.locations.map((location) =>
input.sdk.api.permission.request
.list({ location: { directory: location.directory, workspace: location.workspaceID } })
.then((pending) => {
if (!state.disposed) pending.data.forEach((request) => approve(request))
return true
})
.catch(() => false),
),
)
return inventory.complete && listed.every(Boolean)
}
// Active sessions are the primary inventory: session.active is server-wide,
// so it covers sessions no tab has loaded, and a request blocking a tool
// call always belongs to one (Permission.assert clears its entry when the
// awaiting fiber dies). Locally known sessions are swept too because the
// external session.permission.create API can park a request on an idle
// session. A detached request on a session this client never loaded is the
// one case that stays uncovered.
async function sweepLocations() {
const active = await input.sdk.api.session.active().catch(() => undefined)
const ids = Object.keys(active ?? {})
// Resync every active session rather than trusting cached info: another
// client may have moved one while this client was disconnected, and the
// cached location would list permissions from the old location. A failed
// resync falls back to the cached location and marks the sweep incomplete.
const synced = await Promise.all(
ids.map((id) => {
input.data.session.invalidate(id)
return input.data.session.sync(id).then(
() => true,
() => false,
)
}),
)
const locations = [
...ids.flatMap((id) => {
const location = input.data.session.get(id)?.location
return location ? [location] : []
}),
...input.data.session.list().map((session) => session.location),
]
return {
locations: [
...new Map(locations.map((item) => [`${item.directory}\u0000${item.workspaceID ?? ""}`, item])).values(),
],
complete: active !== undefined && synced.every(Boolean),
}
}
function approve(permission: PermissionRequest, attempt = 0) {
// enabled() guards the retry timer path: the user may disable the setting
// between a failed reply and its scheduled retry.
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
// of times. Later sweeps retry it after that.
state.responded.delete(permission.id)
if (state.disposed || attempt >= retryLimit) return
setTimeout(() => approve(permission, attempt + 1), retryDelayMs * (attempt + 1))
})
}
function remember(id: string) {
state.responded.add(id)
for (const oldest of state.responded) {
if (state.responded.size <= respondedLimit) break
state.responded.delete(oldest)
}
}
}
@@ -1,129 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, relocateAutoAccept, sessionAutoAccept } from "./auto-respond"
const session = (input: { id: string; parentID?: string }) =>
({
id: input.id,
parentID: input.parentID,
}) as SessionInfo
const permission = (sessionID: string) =>
({
sessionID,
}) as Pick<PermissionRequest, "sessionID">
describe("autoRespondsPermission", () => {
test("uses a parent session's directory-scoped auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
test("defaults to requiring approval when no lineage override exists", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
const autoAccept = {
other: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
})
test("inherits a parent session's false override", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("prefers a child override over parent override", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/root`]: false,
[`${base64Encode(directory)}/child`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
test("falls back to directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
})
test("session-level override takes precedence over directory-level", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})
test("parent false override takes precedence over directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("parent true override takes precedence over disabled directory fallback", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: false,
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
})
describe("isDirectoryAutoAccepting", () => {
test("returns true when directory key is set", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
})
test("returns false when directory key is not set", () => {
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
})
test("returns false when directory key is explicitly false", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
})
})
test("relocates bare session settings when the directory becomes known", () => {
const directory = "/tmp/project"
expect(relocateAutoAccept({ root: true }, [{ id: "root" }], directory)).toEqual({
[`${base64Encode(directory)}/root`]: true,
})
expect(
relocateAutoAccept({ root: true, [`${base64Encode(directory)}/root`]: false }, [{ id: "root" }], directory),
).toEqual({ [`${base64Encode(directory)}/root`]: false })
})
@@ -1,79 +0,0 @@
import { base64Encode } from "@opencode-ai/util/encode"
export function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
}
export function directoryAcceptKey(directory: string) {
return `${base64Encode(directory)}/*`
}
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
return autoAccept[acceptKey(sessionID, directory)]
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
const key = directoryAcceptKey(directory)
return autoAccept[key] ?? false
}
export function relocateAutoAccept(
autoAccept: Record<string, boolean>,
sessions: readonly { id: string }[],
directory: string,
) {
const moves = sessions.flatMap((session) => {
const value = autoAccept[session.id]
if (value === undefined) return []
return [{ source: session.id, target: acceptKey(session.id, directory), value }]
})
if (moves.length === 0) return autoAccept
const next = { ...autoAccept }
for (const move of moves) {
if (next[move.target] === undefined) next[move.target] = move.value
delete next[move.source]
}
return next
}
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
const parent = session.reduce((acc, item) => {
if (item.parentID) acc.set(item.id, item.parentID)
return acc
}, new Map<string, string>())
const seen = new Set([sessionID])
const ids = [sessionID]
for (const id of ids) {
const parentID = parent.get(id)
if (!parentID || seen.has(parentID)) continue
seen.add(parentID)
ids.push(parentID)
}
return ids
}
export function autoRespondsPermission(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
const value = sessionAutoAccept(autoAccept, session, permission, directory)
if (value !== undefined) return value
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
}
export function sessionAutoAccept(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
return sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined)
}
+4 -5
View File
@@ -5,7 +5,7 @@ import { useParams } from "@solidjs/router"
import { showToast } from "@/shell/notifications/toast"
import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { usePermission } from "@/session/requests/permission"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { useData } from "@/runtime/server/current"
@@ -16,7 +16,7 @@ export function createSessionRequestModel() {
const serverSDK = useServerSDK()
const data = useData()
const language = useLanguage()
const permission = usePermission()
const settings = useSettings()
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
@@ -32,9 +32,8 @@ export function createSessionRequestModel() {
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
return !permission.autoResponds(item, sdk().directory)
})
if (settings.permissions.autoApprove()) return undefined
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id)
})
const blocked = createMemo(() => {
@@ -1,3 +0,0 @@
import { useServer } from "@/runtime/server/current"
export const usePermission = () => useServer().ctx.permission
@@ -1,348 +0,0 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { ServerSDK } from "@/runtime/server/client"
import type { ServerSync } from "@/runtime/server/sync"
import type { Data } from "@opencode-ai/client/solid"
import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/runtime/persistence/base64"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { type DraftTab, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { requireServerKey } from "@/shell/routes/session"
import { ServerScope } from "@/runtime/server/scope"
import {
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
relocateAutoAccept,
sessionAutoAccept,
} from "./auto-respond"
type PermissionRespondFn = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
directory?: string
}) => void
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
if (typeof rule !== "object") return false
if (Array.isArray(rule)) return false
for (const action of Object.values(rule)) {
if (action !== "allow") return true
}
return false
}
function hasPermissionPromptRules(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
if (Array.isArray(permission)) return false
const config = permission as Record<string, unknown>
return Object.values(config).some(isNonAllowRule)
}
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(input.sdk.scope, "permission"),
...(input.sdk.scope === ServerScope.local ? { previousKey: "permission.v3" } : {}),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
function enableConfiguredDirectory(directory: string) {
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] !== undefined) return
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
const meta = { disposed: false }
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
if (now - ts < RESPONDED_TTL_MS) break
responded.delete(id)
}
for (const id of responded.keys()) {
if (responded.size <= MAX_RESPONDED) break
responded.delete(id)
}
}
const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return
input.sdk.api.permission
.reply({
sessionID: request.sessionID,
requestID: request.permissionID,
reply: request.response,
})
.catch(() => {
responded.delete(request.permissionID)
})
}
const list = async (directory: string) => {
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data)
}
function respondOnce(permission: PermissionRequest, directory?: string) {
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
function sessions(_directory?: string) {
return input.data.session.list()
}
function autoAccept(directory?: string) {
if (!directory) return store.autoAccept
const next = relocateAutoAccept(store.autoAccept, sessions(directory), directory)
if (next !== store.autoAccept) setStore("autoAccept", reconcile(next))
return next
}
function isAutoAccepting(sessionID: string, directory?: string) {
return autoRespondsPermission(autoAccept(directory), sessions(directory), { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
return autoRespondsPermission(autoAccept(directory), sessions(directory), permission, directory)
}
function isPending(permission: PermissionRequest) {
const pending = input.data.session.permission.list(permission.sessionID)
return pending === undefined || pending.some((item) => item.id === permission.id)
}
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
const override = sessionAutoAccept(autoAccept(directory), sessions(directory), permission, directory)
if (override !== undefined) return override
const loaded = new Set<string>()
while (!loaded.has(input.data.session.root(permission.sessionID))) {
const root = input.data.session.root(permission.sessionID)
loaded.add(root)
if (input.data.session.get(root)) break
await input.data.session.sync(root).catch(() => undefined)
}
if (meta.disposed || !input.data.session.get(permission.sessionID)) return false
return shouldAutoRespond(permission, directory)
}
async function respondPending(
permission: PermissionRequest,
directory?: string,
current: () => boolean = () => true,
) {
if (!current() || !isPending(permission)) return
if (!(await shouldAutoRespondResolved(permission, directory))) return
if (meta.disposed || !current() || !isPending(permission)) return
respondOnce(permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (ready()) {
void respondPending(event.data, event.location?.directory)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
void respondPending(event.data, event.location?.directory)
})
})
onCleanup(() => {
meta.disposed = true
unsubscribe()
})
function enableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const permission of permissions) {
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
if (meta.disposed) return
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const permission of permissions) {
void respondPending(
permission,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
if (meta.disposed) return
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
const api = {
ready: () => !meta.disposed && ready(),
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
if (meta.disposed) return false
return shouldAutoRespond(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
if (meta.disposed) return false
return isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
if (meta.disposed) return false
return isAutoAcceptingDirectory(directory)
},
toggleAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (meta.disposed) return
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
if (meta.disposed) return
disable(sessionID, directory)
},
isPermissionAllowAll(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return childStore.config.permission === "allow"
},
}
return {
...api,
api,
sync: input.sync,
enableConfiguredDirectory,
permissionsEnabled(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return hasPermissionPromptRules(childStore.config.permission)
},
}
}
@@ -68,6 +68,14 @@ export function createTimelineController(input: { session: TimelineSessionSource
input.session.data.info()?.revert?.messageID,
)
})
const pendingUserMessageIDs = createMemo(() => {
const id = input.session.identity.sessionID()
return new Set(
(id ? data.session.pending.list(id) : []).flatMap((item) =>
item.type === "user" && item.delivery === "steer" ? [item.id] : [],
),
)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
const parentMessages = createMemo(() => {
@@ -97,6 +105,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
sessionMessages: projectedMessages,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -8,6 +8,7 @@ export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
}) {
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
@@ -75,7 +76,12 @@ export function createTimelineProjection(input: {
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status()),
Timeline.constructSessionMessageRows(
input.sessionMessages(),
input.showReasoningSummaries(),
input.status(),
input.pendingUserMessageIDs(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
+1 -4
View File
@@ -1,4 +1,3 @@
import { useParams } from "@solidjs/router"
import { onCleanup } from "solid-js"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
@@ -6,7 +5,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
export function useSettingsDialog(defaultValue?: string) {
const dialog = useDialog()
const params = useParams<{ id?: string }>()
let run = 0
let dead = false
@@ -16,10 +14,9 @@ export function useSettingsDialog(defaultValue?: string) {
return () => {
const current = ++run
const sessionID = params.id
void import("@/settings/shell").then((module) => {
if (dead || run !== current) return
void dialog.show(() => <module.DialogSettings sessionID={sessionID} defaultValue={defaultValue} />)
void dialog.show(() => <module.DialogSettings defaultValue={defaultValue} />)
})
}
}
@@ -1,7 +1,6 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/session/requests/permission"
import {
monoDefault,
monoFontFamily,
@@ -17,43 +16,11 @@ import {
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
import { createSoundPreviewController, type ShellOption } from "./behavior"
import { ServerConnection } from "@/runtime/server/registry"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { useServerCtx } from "@/runtime/server/runtime"
export { createShellOptions, createSoundPreviewController } from "./behavior"
export type { ShellOption, ShellSelectOption } from "./behavior"
export function createPermissionScopeController(
server: Accessor<ServerConnection.Any | undefined>,
sessionID: Accessor<string | undefined>,
) {
const serverCtx = useServerCtx(server)
const permission = () => serverCtx()?.permission
const directory = createMemo(() => {
const s = server()
const id = sessionID()
if (!s || !id) return undefined
return serverCtx()?.data.session.get(id)?.location.directory
})
return {
accepting: createMemo(() => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission()?.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission()?.enableAutoAccept(id, dir)
permission()?.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
const serverCtx = useServerCtx(server)
const [shells] = createResource(
@@ -172,7 +139,6 @@ export function createSoundSettingsController() {
}
}
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
+5 -12
View File
@@ -13,11 +13,9 @@ import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import {
createAppearanceSettingsController,
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
} from "./controllers"
import "@/settings/settings.css"
@@ -47,8 +45,9 @@ const fontSettings = {
input: "setTerminal",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const AutoApprovePermissionsSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
return (
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
@@ -56,9 +55,8 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
>
<div data-action="settings-auto-accept-permissions">
<Switch
checked={props.controller.accepting()}
disabled={!props.controller.enabled()}
onChange={props.controller.set}
checked={settings.permissions.autoApprove()}
onChange={(checked) => settings.permissions.setAutoApprove(checked)}
/>
</div>
</SettingsRow>
@@ -262,7 +260,6 @@ const LanguageSetting = () => {
}
export const SettingsGeneral: Component<{
sessionID?: string
server?: ServerConnection.Any
}> = (props) => {
const language = useLanguage()
@@ -270,10 +267,6 @@ export const SettingsGeneral: Component<{
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(
() => props.server,
() => props.sessionID,
)
const shell = createShellSettingsController(() => props.server)
const desktop = createMemo(() => platform.platform === "desktop")
@@ -297,7 +290,7 @@ export const SettingsGeneral: Component<{
<LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} />
<AutoApprovePermissionsSetting />
<ShellSetting controller={shell} />
<TerminalPlacementSetting />
+2 -3
View File
@@ -23,7 +23,6 @@ import { ServerConnection, useServers } from "@/runtime/server/registry"
import "@/settings/settings.css"
export const DialogSettings: Component<{
sessionID?: string
defaultValue?: string
}> = (props) => {
const language = useLanguage()
@@ -68,7 +67,7 @@ export const DialogSettings: Component<{
})
const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
void dialog.show(() => <DialogSettings defaultValue="providers" />)
}
return (
@@ -144,7 +143,7 @@ export const DialogSettings: Component<{
</Tabs.List>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral server={server()} sessionID={props.sessionID} />
<SettingsGeneral server={server()} />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />
@@ -2,14 +2,15 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
directory: Accessor<string>,
sessionId: Accessor<string>,
root?: Accessor<boolean>,
) {
const global = useGlobal()
const settings = useSettings()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const serverCtx = useServerCtx(connection)
const sessions = createMemo(() => {
@@ -23,12 +24,10 @@ export function useSessionTabAvatarState(
})
})
const hasPermissions = createMemo(() => {
if (settings.permissions.autoApprove()) return false
const ctx = serverCtx()
if (!ctx) return false
const permission = ctx.permission
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
const hasQuestions = createMemo(() => {
const data = serverCtx()?.data
@@ -15,7 +15,6 @@ export function SessionTabAvatar(props: {
}) {
const state = useSessionTabAvatarState(
() => props.server,
() => props.directory,
() => props.sessionId,
)
return (
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test"
import { windowsMenuAccelerator } from "./windows-menu"
describe("Windows app menu", () => {
test("resolves the new window accelerator", () => {
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N", ctrlKey: true, shiftKey: true }))).toBe(
"window.new",
)
})
test("ignores the accelerator without its modifiers", () => {
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N" }))).toBeUndefined()
})
})
@@ -1,9 +1,10 @@
import { For, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { For, onMount, type JSX } from "solid-js"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useCommand } from "@/shell/commands/command"
import { matchKeybind, parseKeybind, useCommand } from "@/shell/commands/command"
import {
DESKTOP_MENU,
desktopMenuVisible,
@@ -13,6 +14,15 @@ import {
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
const accelerators = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).flatMap((entry) => {
if (entry.type === "separator" || !entry.action || !entry.accelerator?.windows) return []
return [{ action: entry.action, keybind: parseKeybind(entry.accelerator.windows) }]
})
export function windowsMenuAccelerator(event: KeyboardEvent) {
return accelerators.find((entry) => matchKeybind(entry.keybind, event))?.action
}
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform>
@@ -50,6 +60,22 @@ export function WindowsAppMenu(props: {
if (entry.href) props.platform.openExternal(entry.href)
}
onMount(() => {
makeEventListener(
document,
"keydown",
(event) => {
if (event.defaultPrevented) return
const action = windowsMenuAccelerator(event)
if (!action) return
event.preventDefault()
event.stopPropagation()
runAction(action)
},
{ capture: true },
)
})
return (
<Menu appearance="standard" gutter={4} modal={false} placement="bottom-start">
<div
+40 -1
View File
@@ -1,6 +1,14 @@
import { Argument, Flag } from "effect/unstable/cli"
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
import { Schema } from "effect"
import { Spec } from "../framework/spec"
export const PrintLogs = GlobalFlag.setting("print-logs")({
flag: Flag.boolean("print-logs").pipe(
Flag.withDescription("Print logs to stderr (server logs require --standalone)"),
Flag.withDefault(false),
),
})
declare const OPENCODE_CLI_NAME: string | undefined
const ServerParams = {
@@ -187,6 +195,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
description: "List all available models",
params: ServerParams,
}),
Spec.make("stats", {
description: "Show shareable usage statistics",
params: {
...ServerParams,
days: Flag.integer("days").pipe(
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
Flag.withDescription("Show the last N days; 0 means today"),
Flag.optional,
),
year: Flag.integer("year").pipe(
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1970, maximum: 9_999 }))),
Flag.withDescription("Show a calendar year"),
Flag.optional,
),
all: Flag.boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)),
project: Flag.string("project").pipe(
Flag.withDescription('Filter by project ID, or use "." for the current project'),
Flag.optional,
),
models: Flag.boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)),
tools: Flag.boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)),
cost: Flag.boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)),
full: Flag.boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)),
limit: Flag.integer("limit").pipe(
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
Flag.withDescription("Number of rows in detailed sections"),
Flag.withDefault(5),
),
json: Flag.boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
},
}),
Spec.make("export", {
description: "Export session data as JSON",
params: {
+408
View File
@@ -0,0 +1,408 @@
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { errorMessage } from "../../util/error"
const handler = Effect.fn("cli.stats")(function* (input: Runtime.Input<typeof Commands.commands.stats>) {
const days = Option.getOrUndefined(input.days)
const year = Option.getOrUndefined(input.year)
const project = Option.getOrUndefined(input.project)
if ([days !== undefined, year !== undefined, input.all].filter(Boolean).length > 1)
yield* Effect.fail(new Error("--days, --year, and --all cannot be combined"))
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
const range = statsRange({ days, year, all: input.all })
const projectID =
project === "."
? yield* request(server.endpoint.url, (signal) =>
client.location
.get({ location: { directory: process.cwd() } }, { signal })
.then((location) => location.project.id),
)
: project
const details = input.models || input.tools || input.cost || input.full
const stats = yield* request(server.endpoint.url, (signal) =>
client.session.stats(
{
from: range.from,
to: range.to,
project: projectID,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
tools: input.json || input.tools || input.full ? "detail" : details ? "none" : "summary",
},
{ signal },
),
)
const output = input.json
? JSON.stringify(stats, null, 2)
: renderStats(stats, {
label: range.label,
scope: project === undefined ? "all projects" : project === "." ? "current project" : "selected project",
models: input.models || input.full,
tools: input.tools || input.full,
cost: input.cost || input.full,
limit: input.limit,
color: process.stdout.isTTY && process.env.NO_COLOR === undefined,
width: process.stdout.columns ?? 80,
})
process.stdout.write(output + EOL)
})
export default Runtime.handler(Commands.commands.stats, (input) =>
handler(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
export function request<A>(url: string, run: (signal: AbortSignal) => Promise<A>) {
return Effect.tryPromise({
try: () => run(AbortSignal.timeout(30_000)),
catch: (cause) =>
cause instanceof ClientError && cause.reason === "Transport"
? new Error(`Could not reach server at ${url}`, { cause })
: cause,
})
}
type RenderOptions = {
label: string
scope: string
models: boolean
tools: boolean
cost: boolean
limit: number
color: boolean
width: number
}
const colors = terminalPalette()
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
const totalTokens = tokenTotal(stats.tokens)
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
const primary = `1;${colors.primary}`
const sessionLine = [
metricCount(stats.sessions, "session", options.color),
stats.subagents > 0 ? metricCount(stats.subagents, "subagent", options.color) : undefined,
]
.filter((value) => value !== undefined)
.join(" · ")
const toolSummary = !toolTotals
? "tool stats unavailable"
: toolRate === undefined
? "no tool calls"
: `${style(formatPercent(toolRate), primary, options.color)} tool success`
const details = options.models || options.tools || options.cost
const empty = stats.sessions === 0 && stats.prompts === 0 && stats.steps === 0
const heading = `${style("opencode stats", primary, options.color)} ${style(`· ${options.label} · ${options.scope}`, "2", options.color)}`
const lines = details
? [style(`${options.label} · ${options.scope}`, "2", options.color)]
: empty
? [
heading,
"",
style("no activity in this range", "2", options.color),
"",
style("opencode.ai", "2", options.color),
]
: [
heading,
"",
...renderActivity(stats.activity, stats.range.from, stats.range.to, options.color, options.width),
"",
sessionLine,
`${metricCount(stats.prompts, "prompt", options.color)} · ${metricCount(stats.steps, "step", options.color)} · ${metricCount(totalTokens, "token", options.color)}`,
`${toolSummary} · ${metricCount(stats.activeDays, "active day", options.color)} · best streak ${style(stats.streak.toString(), primary, options.color)} day${stats.streak === 1 ? "" : "s"}`,
"",
style("opencode.ai", "2", options.color),
]
if (options.cost) lines.push(...(lines.length > 0 ? [""] : []), ...renderCost(stats))
if (options.models)
lines.push(...(lines.length > 0 ? [""] : []), ...renderModels(stats, options.limit, options.width))
if (options.tools) lines.push(...(lines.length > 0 ? [""] : []), ...renderTools(stats, options.limit, options.width))
return lines.join(EOL)
}
function statsRange(input: { days?: number; year?: number; all: boolean }) {
const now = new Date()
const to = now.getTime() + 1
if (input.all) return { from: undefined, to, label: "all time" }
if (input.days !== undefined) {
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate())
from.setDate(from.getDate() - Math.max(0, input.days - 1))
return {
from: from.getTime(),
to,
label: input.days === 0 || input.days === 1 ? "today" : `last ${input.days} days`,
}
}
const year = input.year ?? now.getFullYear()
return {
from: new Date(year, 0, 1).getTime(),
to: year === now.getFullYear() ? to : new Date(year + 1, 0, 1).getTime(),
label: year === now.getFullYear() ? `${year} so far` : year.toString(),
}
}
function renderActivity(
activity: SessionStatsInfo["activity"],
from: number,
to: number,
color: boolean,
width: number,
) {
const values = new Map(activity.map((day) => [day.date, day.steps]))
const rangeStart = dateOrdinal(new Date(from))
const rangeEnd = dateOrdinal(new Date(to - 1))
const end = new Date(to - 1)
end.setHours(12, 0, 0, 0)
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
const start = new Date(from)
start.setHours(12, 0, 0, 0)
start.setDate(start.getDate() - mondayIndex(start))
const maxWeeks = Math.max(1, Math.min(53, width - 4))
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
const latest = new Date(end)
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
if (start < latest) start.setTime(latest.getTime())
const active = [...values.values()].filter((value) => value > 0)
const levels = [...new Set(active)].sort((a, b) => a - b)
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
const date = new Date(start)
date.setDate(date.getDate() + week * 7)
return date
})
const weeks = weekStarts.map((week) =>
Array.from({ length: 7 }, (_, day) => {
const date = new Date(week)
date.setDate(date.getDate() + day)
const ordinal = dateOrdinal(date)
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
}),
)
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
return [
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
` ${style(monthLabels(weekStarts), "2", color)}`,
...weekdays.flatMap((label, day) => [
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
...(day === weekdays.length - 1 ? [] : [""]),
]),
"",
` ${style("less", "2", color)} ${[0, 1, 2, 3, 4].map((level) => paintActivity(level, color)).join("")} ${style("more", "2", color)}`,
]
}
function renderCost(stats: SessionStatsInfo) {
const input = stats.tokens.input + stats.tokens.cache.read + stats.tokens.cache.write
const cached = input === 0 ? 0 : (stats.tokens.cache.read / input) * 100
return [
"COST & TOKENS",
row("cost", `$${stats.cost.toFixed(2)}`),
row("input", formatNumber(stats.tokens.input)),
row("output", formatNumber(stats.tokens.output)),
row("reasoning", formatNumber(stats.tokens.reasoning)),
row("cache read", formatNumber(stats.tokens.cache.read)),
row("cache write", formatNumber(stats.tokens.cache.write)),
row("cached input", formatPercent(cached)),
]
}
function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
if (stats.models.length === 0) return ["MODELS", " no model usage"]
const models = stats.models.slice(0, limit)
const more = stats.models.length - models.length
if (width < 68)
return [
"MODELS",
...models.flatMap((item) => [
truncate(
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
width,
),
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
]),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
return [
"MODELS",
tableHeader("model", "tokens", "steps", "cost"),
...models.map((item) =>
tableRow(
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
formatNumber(tokenTotal(item.tokens)),
formatNumber(item.steps),
`$${item.cost.toFixed(2)}`,
),
),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
}
function renderTools(stats: SessionStatsInfo, limit: number, width: number) {
if (stats.tools.mode !== "detail") return ["TOOL RELIABILITY", " tool details unavailable"]
if (stats.tools.usage.length === 0) return ["TOOL RELIABILITY", " no tool calls"]
const tools = stats.tools.usage.slice(0, limit)
const more = stats.tools.usage.length - tools.length
if (width < 68)
return [
"TOOL RELIABILITY",
...tools.flatMap((tool) => {
const terminal = tool.succeeded + tool.failed
return [
truncate(tool.name, width),
` ${formatNumber(tool.calls)} calls · ${terminal === 0 ? "-" : formatPercent((tool.failed / terminal) * 100)} error · ${tool.durationP50 === undefined ? "-" : formatDuration(tool.durationP50)} p50`,
]
}),
"",
`${formatNumber(stats.tools.totals.succeeded + stats.tools.totals.failed)} finished calls · ${formatNumber(stats.tools.totals.unfinished)} unfinished`,
...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []),
]
return [
"TOOL RELIABILITY",
tableHeader("tool", "calls", "error", "p50"),
...tools.map((tool) => {
const terminal = tool.succeeded + tool.failed
return tableRow(
tool.name,
formatNumber(tool.calls),
terminal === 0 ? "-" : formatPercent((tool.failed / terminal) * 100),
tool.durationP50 === undefined ? "-" : formatDuration(tool.durationP50),
)
}),
"",
`${formatNumber(stats.tools.totals.succeeded + stats.tools.totals.failed)} finished calls · ${formatNumber(stats.tools.totals.unfinished)} unfinished`,
...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []),
]
}
function row(label: string, value: string) {
return ` ${label.padEnd(20)}${value}`
}
function tableHeader(label: string, second: string, third: string, fourth: string) {
return tableRow(label, second, third, fourth)
}
function tableRow(label: string, second: string, third: string, fourth: string) {
return `${truncate(label, 34).padEnd(34)}${second.padStart(10)}${third.padStart(12)}${fourth.padStart(12)}`
}
function truncate(value: string, width: number) {
return value.length <= width ? value : value.slice(0, width - 1) + "…"
}
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
function formatNumber(value: number) {
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
if (value >= 1_000) return `${trimDecimal(value / 1_000)}k`
return Math.round(value).toLocaleString("en-US")
}
function trimDecimal(value: number) {
return value.toFixed(1).replace(/\.0$/, "")
}
function formatPercent(value: number) {
return `${value.toFixed(value >= 10 ? 1 : 2)}%`
}
function formatDuration(value: number) {
if (value < 1_000) return `${Math.round(value)}ms`
return `${trimDecimal(value / 1_000)}s`
}
function metricCount(value: number, noun: string, color: boolean) {
return `${style(formatNumber(value), `1;${colors.primary}`, color)} ${noun}${value === 1 ? "" : "s"}`
}
function style(value: string, code: string, color: boolean) {
return color ? `\x1b[${code}m${value}\x1b[0m` : value
}
function activityGlyph(value: number, levels: number[], color: boolean) {
if (value === 0) return paintActivity(0, color)
const index = levels.indexOf(value)
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
return paintActivity(level, color)
}
function paintActivity(level: number, color: boolean) {
const glyph = ["·", "░", "▒", "▓", "█"][level]
if (!color) return glyph
if (level === 0) return `\x1b[2m${glyph}\x1b[22m`
return `\x1b[${colors.activity[level - 1]}m${glyph}\x1b[39m`
}
function terminalPalette() {
const background = Number(process.env.COLORFGBG?.split(";").at(-1))
if (Number.isFinite(background) && background >= 7)
return {
primary: "38;2;59;125;216",
activity: ["38;2;153;169;192", "38;2;122;155;200", "38;2;90;140;208", "38;2;59;125;216"],
}
if (Number.isFinite(background))
return {
primary: "38;2;250;178;131",
activity: ["38;2;117;99;87", "38;2;161;125;102", "38;2;206;152;116", "38;2;250;178;131"],
}
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
}
function monthLabels(weeks: Date[]) {
const line: string[] = []
weeks.reduce((previous, week, index) => {
const middle = new Date(week)
middle.setDate(middle.getDate() + 3)
const month = middle.getMonth()
if (month === previous) return previous
Intl.DateTimeFormat("en-US", { month: "short" })
.format(middle)
.split("")
.forEach((character, offset) => {
line[index + offset] = character
})
return month
}, -1)
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
.join("")
.trimEnd()
}
function mondayIndex(date: Date) {
return (date.getDay() + 6) % 7
}
function dateKey(date: Date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-")
}
function dateOrdinal(date: Date) {
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
}
+7 -1
View File
@@ -1,5 +1,6 @@
import { Effect, FileSystem, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { PrintLogs } from "../commands/commands"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
@@ -77,7 +78,11 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
}
export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
return Command.run(provide(commands, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
return Command.run(provide(commands, handlers).pipe(Command.withGlobalFlags([PrintLogs])), options) as Effect.Effect<
void,
unknown,
Command.Environment
>
}
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
@@ -86,6 +91,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
? node.spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
if (yield* PrintLogs) process.env.OPENCODE_PRINT_LOGS = "1"
const module = yield* Effect.promise(handler.load)
return yield* module.default(input)
}),
+1
View File
@@ -43,6 +43,7 @@ const Handlers = Runtime.handlers(Commands, {
remove: () => import("./commands/handlers/plugin/remove"),
},
models: () => import("./commands/handlers/models"),
stats: () => import("./commands/handlers/stats"),
export: () => import("./commands/handlers/export"),
import: () => import("./commands/handlers/import"),
mini: () => import("./commands/handlers/mini"),
-1
View File
@@ -1,5 +1,4 @@
import "./plugin-runtime.promise"
import "./plugin-runtime.effect"
process.stdout.on("error", (error) => {
if ("code" in error && error.code === "EPIPE") return
@@ -1,28 +0,0 @@
import {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
const key = Symbol.for("opencode.plugin.v2.effect")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
Tool: { Error: Tool.Error },
}
+1 -1
View File
@@ -27,7 +27,7 @@ function command(password: string, options: Options) {
// The server treats EOF on this pipe as the end of its ownership lease.
// The OS closes it even when the TUI is killed before Effect finalizers run.
stdin: "pipe",
stderr: "ignore",
stderr: process.env.OPENCODE_PRINT_LOGS === "1" ? "inherit" : "ignore",
killSignal: "SIGTERM",
forceKillAfter: "3 seconds",
})
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, test } from "bun:test"
import { ClientError, type SessionStatsInfo } from "@opencode-ai/client"
import { Effect } from "effect"
import { renderStats, request } from "../src/commands/handlers/stats"
const tools = {
mode: "detail",
totals: { calls: 10, succeeded: 8, failed: 2, unfinished: 0 },
usage: [{ name: "private_tool", calls: 10, succeeded: 8, failed: 2, unfinished: 0, durationP50: 250 }],
} satisfies SessionStatsInfo["tools"]
const stats: SessionStatsInfo = {
range: { from: Date.UTC(2026, 0, 1), to: Date.UTC(2026, 0, 8) },
sessions: 2,
subagents: 1,
prompts: 4,
steps: 6,
tokens: { input: 10_000, output: 2_000, reasoning: 1_000, cache: { read: 5_000, write: 500 } },
cost: 12.34,
tools,
activeDays: 2,
streak: 2,
activity: [
{ date: "2026-01-02", steps: 2 },
{ date: "2026-01-03", steps: 4 },
],
models: [
{
model: { providerID: "anthropic", id: "sonnet" },
steps: 6,
tokens: { input: 10_000, output: 2_000, reasoning: 1_000, cache: { read: 5_000, write: 500 } },
cost: 12.34,
},
],
}
describe("stats rendering", () => {
test("keeps the default card shareable", () => {
const output = renderStats(stats, options())
expect(output).toContain("opencode stats · 2026 so far · all projects")
expect(output).toContain("activity")
expect(output).toMatch(/Mo .*(?:\r?\n){2}Tu/)
expect(output).toMatch(/Su .*(?:\r?\n){2} less/)
expect(output).toContain("less ·░▒▓█ more")
expect(output).toContain("2 sessions · 1 subagent")
expect(output).toContain("80.0% tool success · 2 active days · best streak 2 days")
expect(output).not.toContain("private_tool")
expect(output).not.toContain("$12.34")
})
test("renders only requested detail tables", () => {
const output = renderStats(stats, options({ tools: true, cost: true }))
expect(output).toContain("COST & TOKENS")
expect(output).toContain("TOOL RELIABILITY")
expect(output).toContain("private_tool")
expect(output).toContain("tool")
expect(output).toContain("calls")
expect(output).toContain("cached input 32.3%")
expect(output).not.toContain("opencode stats")
expect(output).not.toContain("activity")
})
test("shows when detail tables omit rows", () => {
const output = renderStats(
{
...stats,
models: [
...stats.models,
{
model: { providerID: "anthropic", id: "haiku" },
steps: 2,
tokens: { input: 2_000, output: 500, reasoning: 0, cache: { read: 1_000, write: 0 } },
cost: 1.25,
},
],
tools: {
mode: "detail",
totals: tools.totals,
usage: [...tools.usage, { name: "grep", calls: 4, succeeded: 4, failed: 0, unfinished: 0, durationP50: 20 }],
},
},
options({ models: true, tools: true, limit: 1 }),
)
expect(output).toContain("+1 more model")
expect(output).toContain("+1 more tool")
})
test("uses the OpenCode palette in color mode", () => {
const output = renderStats(stats, options({ color: true }))
expect(output).toContain("\x1b[1;36m")
expect(output).not.toContain("38;5;45")
})
test("uses compact layouts in narrow terminals", () => {
const output = renderStats(stats, options({ models: true, width: 48 }))
expect(output).toContain("anthropic/sonnet")
expect(output).toContain("18.5k tokens · 6 steps · $12.34")
expect(output.split(/\r?\n/).every((line) => line.length <= 48)).toBe(true)
})
test("renders a concise empty state", () => {
const output = renderStats(
{ ...stats, sessions: 0, subagents: 0, prompts: 0, steps: 0, activeDays: 0, streak: 0, activity: [] },
options(),
)
expect(output).toContain("no activity in this range")
expect(output).not.toContain("less ·░▒▓█ more")
})
test("does not present uncollected tools as zero calls", () => {
const output = renderStats({ ...stats, tools: { mode: "none" } }, options())
expect(output).toContain("tool stats unavailable")
expect(output).not.toContain("no tool calls")
})
test("labels activity when terminal width truncates the requested range", () => {
const output = renderStats(
{ ...stats, range: { from: Date.UTC(2020, 0, 1), to: Date.UTC(2026, 0, 8) } },
options({ width: 20 }),
)
expect(output).toContain("activity · last 16 weeks")
})
})
describe("stats requests", () => {
test("maps transport failures to the server URL", async () => {
const cause = new ClientError("Transport")
const error = await Effect.runPromise(Effect.flip(request("http://localhost:4096", () => Promise.reject(cause))))
expect(error).toEqual(new Error("Could not reach server at http://localhost:4096", { cause }))
})
test("preserves declared API errors", async () => {
const cause = { _tag: "InvalidRequestError", message: "Stats range must end after it starts" } as const
const error = await Effect.runPromise(Effect.flip(request("http://localhost:4096", () => Promise.reject(cause))))
expect(error).toBe(cause)
})
test("preserves unexpected response failures", async () => {
const cause = new ClientError("UnexpectedStatus", { cause: { status: 500 } })
const error = await Effect.runPromise(Effect.flip(request("http://localhost:4096", () => Promise.reject(cause))))
expect(error).toBe(cause)
})
})
function options(input: Partial<Parameters<typeof renderStats>[1]> = {}): Parameters<typeof renderStats>[1] {
return {
label: "2026 so far",
scope: "all projects",
models: false,
tools: false,
cost: false,
limit: 5,
color: false,
width: 80,
...input,
}
}
-16
View File
@@ -132,20 +132,10 @@ export const Plugin = sdk.Plugin
export const Provider = sdk.Provider
export const Reference = sdk.Reference
export const Skill = sdk.Skill`
const effectModule = promiseModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promiseToolModule = `export {}`
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Error = sdk.Tool.Error
`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
@@ -160,17 +150,11 @@ const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin": "opencode:plugin-v2",
"@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin",
"@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool",
"@opencode-ai/plugin/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool",
})}
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-promise-plugin": promisePluginModule,
"opencode:plugin-promise-tool": promiseToolModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
})}
__cjs_mod__.registerHooks({
resolve(__ocSpecifier, __ocContext, __ocNextResolve) {
+67
View File
@@ -10,6 +10,7 @@ import type { Project } from "@opencode-ai/schema/project"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model"
import type { DateTime } from "effect"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
@@ -111,6 +112,71 @@ export type SessionListOutput = {
}
export type SessionListOperation<E = never> = (input?: SessionListInput) => Effect.Effect<SessionListOutput, E>
export type SessionStatsInput = {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: Project.ID | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}
export type SessionStatsOutput = {
readonly range: { readonly from: DateTime.Utc; readonly to: DateTime.Utc }
readonly sessions: number
readonly subagents: number
readonly prompts: number
readonly steps: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly cost: number & Brand.Brand<"Money.USD">
readonly tools:
| { readonly mode: "none" }
| {
readonly mode: "summary"
readonly totals: {
readonly calls: number
readonly succeeded: number
readonly failed: number
readonly unfinished: number
}
}
| {
readonly mode: "detail"
readonly totals: {
readonly calls: number
readonly succeeded: number
readonly failed: number
readonly unfinished: number
}
readonly usage: ReadonlyArray<{
readonly name: string
readonly calls: number
readonly succeeded: number
readonly failed: number
readonly unfinished: number
readonly durationP50?: number | undefined
}>
}
readonly activeDays: number
readonly streak: number
readonly activity: ReadonlyArray<{ readonly date: string; readonly steps: number }>
readonly models: ReadonlyArray<{
readonly model: Model.Ref
readonly steps: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly cost: number & Brand.Brand<"Money.USD">
}>
}
export type SessionStatsOperation<E = never> = (input?: SessionStatsInput) => Effect.Effect<SessionStatsOutput, E>
export type SessionCreateInput = {
readonly id?: Session.ID | undefined
readonly title?: string | undefined
@@ -966,6 +1032,7 @@ export type SessionViewOperation<E = never> = (input: SessionViewInput) => Effec
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly stats: SessionStatsOperation<E>
readonly create: SessionCreateOperation<E>
readonly import: SessionImportOperation<E>
readonly export: SessionExportOperation<E>
@@ -17,6 +17,8 @@ import type {
PluginListOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
SessionStatsOutput,
SessionCreateInput,
SessionCreateOutput,
SessionImportInput,
@@ -305,6 +307,22 @@ const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: Sessi
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionStats = (raw: RawClient["server.session"]) => (input?: SessionStatsInput) =>
preserveEffect<SessionStatsOutput>()(
raw["session.stats"]({
query: {
from: input?.["from"],
to: input?.["to"],
project: input?.["project"],
timezone: input?.["timezone"],
tools: input?.["tools"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: SessionCreateInput) =>
preserveEffect<SessionCreateOutput>()(
raw["session.create"]({
@@ -632,6 +650,7 @@ const EndpointSessionView = (raw: RawClient["server.session"]) => (input: Sessio
const adaptGroupSession = (raw: RawClient["server.session"]) => ({
list: EndpointSessionList(raw),
stats: EndpointSessionStats(raw),
create: EndpointSessionCreate(raw),
import: EndpointSessionImport(raw),
export: EndpointSessionExport(raw),
@@ -11,6 +11,8 @@ import type {
PluginListOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
SessionStatsOutput,
SessionCreateInput,
SessionCreateOutput,
SessionImportInput,
@@ -454,6 +456,24 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
stats: (input?: SessionStatsInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionStatsOutput }>(
{
method: "GET",
path: `/api/session/stats`,
query: {
from: input?.["from"],
to: input?.["to"],
project: input?.["project"],
timezone: input?.["timezone"],
tools: input?.["tools"],
},
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>(
{
@@ -37,6 +37,19 @@ export type FileDiffInfo = {
status: "added" | "deleted" | "modified"
}
export type SessionStatsToolTotals = { calls: number; succeeded: number; failed: number; unfinished: number }
export type SessionStatsToolUsage = {
name: string
calls: number
succeeded: number
failed: number
unfinished: number
durationP50?: number
}
export type SessionStatsActivity = { date: string; steps: number }
export type SessionMessageAgentSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -422,6 +435,13 @@ export type V2EventServerConnected = {
export type SessionRevert = { messageID: string; partID?: string; snapshot?: string; files?: Array<FileDiffInfo> }
export type SessionStatsTools =
| { mode: "none" }
| { mode: "summary"; totals: SessionStatsToolTotals }
| { mode: "detail"; totals: SessionStatsToolTotals; usage: Array<SessionStatsToolUsage> }
export type SessionStatsModelUsage = { model: ModelRef; steps: number; tokens: TokenUsageInfo; cost: MoneyUSD }
export type SessionMessageModelSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -1538,6 +1558,21 @@ export type SessionRevertStaged = {
data: { sessionID: string; revert: SessionRevert }
}
export type SessionStatsInfo = {
range: { from: number; to: number }
sessions: number
subagents: number
prompts: number
steps: number
tokens: TokenUsageInfo
cost: MoneyUSD
tools: SessionStatsTools
activeDays: number
streak: number
activity: Array<SessionStatsActivity>
models: Array<SessionStatsModelUsage>
}
export type SessionMessageUser = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -2446,6 +2481,46 @@ export type SessionListInput = {
export type SessionListOutput = SessionsResponse
export type SessionStatsInput = {
readonly from?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["from"]
readonly to?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["to"]
readonly project?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["project"]
readonly timezone?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["timezone"]
readonly tools?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["tools"]
}
export type SessionStatsOutput = { data: SessionStatsInfo }["data"]
export type SessionCreateInput = {
readonly id?: {
readonly id?: string | null
+35
View File
@@ -32,3 +32,38 @@ const result = await Bun.build({
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
// Bun's Node target eagerly creates its shared require helper, so every split
// entry evaluates import.meta.url even when it never requires a module. Keep
// the helper lazy until Bun stops hoisting it into workerd-reachable chunks.
// https://github.com/oven-sh/bun/issues/12615
const eagerRequire = "var __require = /* @__PURE__ */ createRequire(import.meta.url);"
const lazyRequire = `var __require = (specifier) => createRequire(import.meta.url ?? "file:///worker.js")(specifier);
__require.resolve = (specifier, options) => createRequire(import.meta.url ?? "file:///worker.js").resolve(specifier, options);`
const rewritten = await Promise.all(
result.outputs.map(async (output) => {
if (!output.path.endsWith(".js")) return false
const source = await output.text()
const generatedUses = source
.replace(/import\s*\{[^}]*\b__require\b[^}]*\}\s*from\s*["'][^"']+["'];/g, "")
.replace(/export\s*\{[^}]*\b__require\b[^}]*\};/g, "")
.replace(eagerRequire, "")
if (/\bnew\s+__require\s*\(/.test(generatedUses))
throw new Error(`Unsupported generated require constructor in ${output.path}`)
const unsupported = generatedUses
.replace(/\b__require\.resolve\s*\(/g, "")
.replace(/\b__require\s*\(/g, "")
if (/\b__require\b/.test(unsupported)) throw new Error(`Unsupported generated require usage in ${output.path}`)
if (!source.includes(eagerRequire)) return false
if (source.indexOf(eagerRequire) !== source.lastIndexOf(eagerRequire))
throw new Error(`Multiple eager require helpers in ${output.path}`)
const rewrittenSource = source.replace(eagerRequire, lazyRequire)
if (rewrittenSource.includes(eagerRequire)) throw new Error(`Failed to rewrite eager require helper in ${output.path}`)
await Bun.write(output.path, rewrittenSource)
return true
}),
)
if (rewritten.filter(Boolean).length !== 1)
throw new Error("Expected exactly one eager require helper; Bun may have fixed #12615 and made this shim removable")
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigAgentPlugin from "./agent.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import path from "path"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import path from "path"
@@ -1,6 +1,6 @@
export * as ConfigCompactionPlugin from "./compaction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { SessionCompaction } from "../../session/compaction.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigFormatterPlugin from "./formatter.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigImagePlugin from "./image.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Image } from "../../image.js"
@@ -1,6 +1,6 @@
export * as ConfigInstructionPlugin from "./instruction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { dirname, join } from "path"
@@ -1,6 +1,6 @@
export * as ConfigLocationWatcherPlugin from "./location-watcher.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { LocationWatcherPolicy } from "../../filesystem/location-watcher-policy.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigMCPPlugin from "./mcp.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Effect, Stream } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigPolicyPlugin from "./policy.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document } from "@opencode-ai/schema/config"
import { Effect } from "effect"
import { Config } from "../../config.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigProviderPlugin from "./provider.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigReferencePlugin from "./reference.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document } from "@opencode-ai/schema/config"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import path from "path"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigShellPlugin from "./shell.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ShellSelect } from "../../shell/select.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigSkillPlugin from "./skill.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigSnapshotPlugin from "./snapshot.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Snapshot } from "../../snapshot.js"
@@ -1,6 +1,6 @@
export * as ConfigToolOutputPlugin from "./tool-output.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ToolOutput } from "../../tool-output.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigWebSearchPlugin from "./websearch.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ConfigEntryObserver } from "./entry-observer.js"
+1 -1
View File
@@ -572,7 +572,7 @@ function cacheKey(source: string) {
}
export function bodyDigest(text: string) {
return new Bun.CryptoHasher("sha256").update(text).digest("hex")
return Hash.sha256(text)
}
export const layer = (options?: Options) =>
+1 -1
View File
@@ -2,7 +2,7 @@ export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/core/plugin/definition"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as AgentPlugin from "./agent.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as CommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Location } from "../location.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
+15
View File
@@ -0,0 +1,15 @@
import type { Context as PluginContext, Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import type { Scope } from "effect"
export type Context = PluginContext
export type Plugin<R = Scope.Scope> = PluginDefinition<R>
export function define<R>(plugin: PluginDefinition<R>) {
return plugin
}
export namespace Plugin {
export type Context = PluginContext
export type Plugin<R = Scope.Scope> = PluginDefinition<R>
export const define = <R>(plugin: PluginDefinition<R>) => plugin
}
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Plugin } from "@opencode-ai/core/plugin/definition"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginInternal from "./internal.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import type { Plugin } from "@opencode-ai/core/plugin/definition"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppProcess } from "@opencode-ai/util/process"
@@ -1,6 +1,6 @@
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Integration } from "@opencode-ai/schema/integration"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as PlanPlugin from "./plan.js"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Global } from "@opencode-ai/util/global"
import { Effect, Stream } from "effect"
import path from "path"
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
type MantleSDK = {
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const AnthropicPlugin = define({
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Form } from "@opencode-ai/schema/form"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const CerebrasPlugin = define({
@@ -1,7 +1,7 @@
import os from "os"
import { App } from "../../app.js"
import { Effect, Option, Schema } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Form } from "@opencode-ai/schema/form"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
@@ -1,7 +1,7 @@
import os from "os"
import { App } from "../../app.js"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Form } from "@opencode-ai/schema/form"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import { Effect } from "effect"
@@ -8,7 +8,7 @@ import { App } from "../../app.js"
import { Agent } from "../../agent.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
+1 -1
View File
@@ -1,7 +1,7 @@
import os from "os"
import { App } from "../../app.js"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const GitLabPlugin = define({
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
function resolveProject(options: Record<string, any>) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const KiloPlugin = define({
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Integration } from "../../integration.js"
import { Provider } from "../../provider.js"
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const NvidiaPlugin = define({
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
export const OpenAICompatiblePlugin = define({
id: "opencode.provider.openai.compatible",
+1 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
@@ -1,7 +1,7 @@
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
export const OpenRouterPlugin = define({
id: "opencode.provider.openrouter",
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Npm } from "@opencode-ai/util/npm"
import { Provider } from "../../provider.js"
import { importModule } from "@opencode-ai/util/runtime-import"
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const VercelPlugin = define({
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+1 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Clock, Effect, Option, Schema } from "effect"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const ZenmuxPlugin = define({
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SdkPlugins from "./sdk.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import type { Plugin } from "@opencode-ai/core/plugin/definition"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
+1 -1
View File
@@ -2,7 +2,7 @@
export * as SkillPlugin from "./skill.js"
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { define, type Context } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Skill } from "../skill.js"
+19 -19
View File
@@ -1,9 +1,8 @@
export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Predicate, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -18,22 +17,13 @@ import { importModule } from "@opencode-ai/util/runtime-import"
import { Service } from "./supervisor-service.js"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
effect: Schema.declare<PluginDefinition["effect"]>(
(input): input is PluginDefinition["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
]),
default: Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
})
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
@@ -119,8 +109,18 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
: `${entrypoint}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
if (
Predicate.isObject(mod) &&
Predicate.isObject(mod.default) &&
"effect" in mod.default &&
!("setup" in mod.default)
) {
return yield* Effect.fail(
new Error("Effect plugins must be exported through Plugin.define from @opencode-ai/plugin/effect"),
)
}
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const plugin = PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SystemPromptPlugin from "./system-prompt.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as VariantPlugin from "./variant.js"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WarmingPlugin from "./warming.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Clock, Duration, Effect, Scope } from "effect"
import { Config } from "../config.js"
import { SessionSchema } from "../session/schema.js"

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