mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 11:36:14 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc9f6ea09 | ||
|
|
aa8c1f6dac | ||
|
|
23c3a1461c | ||
|
|
50a8539e4b | ||
|
|
7b47589225 | ||
|
|
84275c6e9d | ||
|
|
793ea52fa7 | ||
|
|
40380ad9b5 | ||
|
|
156cb7dd10 | ||
|
|
0a60910208 | ||
|
|
11e2bde399 | ||
|
|
ea85118516 | ||
|
|
2e1d7c84ab | ||
|
|
3b8949b1ee | ||
|
|
97daae9b77 | ||
|
|
3dd84d629d | ||
|
|
279062d3c9 | ||
|
|
0a410e404b | ||
|
|
5cd40ad66f | ||
|
|
46d1f1fed1 | ||
|
|
e282066cf8 | ||
|
|
6704f34cad |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 +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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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,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 },
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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,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,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 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,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,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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,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,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
Reference in New Issue
Block a user