Compare commits

..

41 Commits

Author SHA1 Message Date
Kit Langton d7ffc7fec1 refactor(session): simplify pending withdrawal 2026-07-30 16:13:18 -04:00
Kit Langton 330ab9acae fix(tui): undo pending session input 2026-07-30 16:13:18 -04:00
Kit Langton 9abd9594de fix(simulation): mirror kitty keyboard protocol (#39741) 2026-07-30 20:03:21 +00:00
Kit Langton 22d2012f75 fix(tui): register storybook only for story runs (#39733) 2026-07-30 15:55:05 -04:00
Kit Langton cc883058df feat(tui): reopen closed session tabs (#39731) 2026-07-30 15:54:43 -04:00
Kit Langton 8ebc4c6f85 fix(tui): call session tabs just tabs (#39730) 2026-07-30 15:52:58 -04:00
Kit Langton b0ed9990b3 feat(tui): add option tab shortcuts (#39725) 2026-07-30 15:51:10 -04:00
Kit Langton e1c04dcce6 feat(tui): add temporary new session tab (#39735) 2026-07-30 15:05:34 -04:00
Kit Langton 188c642d8c refactor(tui): flatten project picker list (#39726) 2026-07-30 14:18:16 -04:00
Kit Langton 9d55b223bb fix(core): stop repository discovery at nearest marker (#39714) 2026-07-30 13:28:29 -04:00
Kit Langton ce7a7e4e23 fix(tui): truncate project picker paths (#39678) 2026-07-30 13:13:31 -04:00
Kit Langton cba5ba03e3 fix(tui): show shells from session location (#39691) 2026-07-30 13:12:01 -04:00
Kit Langton 49081a4e24 fix(tui): flash tabs on prompt admission (#39702) 2026-07-30 16:57:36 +00:00
opencode-agent[bot] d6371f2fcd fix(session): update activity on prompt (#39539)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-30 16:28:10 +00:00
Aiden Cline 7625cbdf47 feat(core): route providers through native AI (#39615) 2026-07-30 11:26:18 -05:00
opencode-agent[bot] 0ffec67ca3 refactor: remove unused V2 code (#39699)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 11:25:03 -05:00
Kit Langton bd132f2614 refactor(core): skip redundant worktree lookup (#39692) 2026-07-30 16:15:07 +00:00
opencode-agent[bot] dcb0df4ac1 fix(core): stop MCP SSE error reconnect loops (#39671)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 10:53:33 -05:00
Kit Langton 02f3504055 fix(tui): hide redundant session directory labels (#39686) 2026-07-30 11:37:33 -04:00
James Long f23ee5e4a8 refactor(core): simplify formatter selection (#39575) 2026-07-30 10:59:01 -04:00
Kit Langton 77aa85c589 feat(tui): project picker with footer crossfade (#39566) 2026-07-30 10:39:22 -04:00
Dax Raad a618946b7e fix(tui): reload plugins with config 2026-07-30 10:31:09 -04:00
opencode-agent[bot] f9de608dea chore: refresh bun lockfile (#39617)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 04:36:21 +00:00
Aiden Cline 906dc8f5b2 fix(ai): apply catalog settings to provider models (#39613) 2026-07-29 22:42:47 -05:00
Aiden Cline 12971935ab feat(core): parse shell permission commands (#39567) 2026-07-29 22:42:18 -05:00
Kit Langton 78c139b8b5 feat(plugin): add ui.tabs API for session tab control (#39591) 2026-07-29 22:57:39 -04:00
Kit Langton c161978852 feat(tui): prefetch open session tabs after connect (#39589) 2026-07-29 22:23:13 -04:00
Kit Langton cb37a7166a feat(tui): make session tab switching fast for long transcripts (#39568) 2026-07-29 22:23:10 -04:00
Dax Raad 488445a679 fix(tui): correct project-aware session lists 2026-07-29 21:59:33 -04:00
opencode-agent[bot] 97786afdd8 refactor(core): share file diff construction (#39586)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-30 00:28:33 +00:00
Aiden Cline b1a5e8a6ae test(tui): restore compaction event lifecycle (#39581) 2026-07-29 18:11:58 -05:00
Aiden Cline 0ece10af43 fix(core): add mutation permission previews (#39578) 2026-07-29 18:04:13 -05:00
Aiden Cline d1a02b149c fix(core): clarify subagent tool guidance (#39572) 2026-07-29 16:48:49 -05:00
James Long 94ee274aeb feat(core): add V2 formatter runtime (#39564) 2026-07-29 17:23:04 -04:00
Kit Langton 3c259fc552 feat(tui): replace scrap screen with component storybook (#39548) 2026-07-29 15:51:15 -04:00
Aiden Cline 210be4b749 fix(core): preserve shell output on timeout (#39559) 2026-07-29 14:31:26 -05:00
James Long 464649e67e feat(tui): batch event delivery (#39551) 2026-07-29 15:20:39 -04:00
Aiden Cline c8dca936b1 feat(plugin): add shell.create.before hook (#39547) 2026-07-29 14:11:52 -05:00
Kit Langton 4f871906bc fix(tui): support cd before session creation (#39555) 2026-07-29 18:45:42 +00:00
Aiden Cline f7ea2fc346 test(cli): update ACP fork expectation (#39554) 2026-07-29 13:21:34 -05:00
Aiden Cline 5cb633a48e feat(core): support pinned Code Mode tools (#39550) 2026-07-29 13:16:49 -05:00
164 changed files with 4632 additions and 2748 deletions
+16 -2
View File
@@ -143,7 +143,10 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0",
},
"devDependencies": {
@@ -402,8 +405,11 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:",
},
@@ -1073,9 +1079,11 @@
},
"trustedDependencies": [
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
@@ -4072,10 +4080,10 @@
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -5634,6 +5642,10 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -6856,6 +6868,8 @@
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
+19 -1
View File
@@ -5,6 +5,7 @@ import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { isRecord } from "../protocols/shared"
@@ -30,6 +31,12 @@ export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
])
@@ -113,4 +120,15 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model = provider.model
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
+50 -12
View File
@@ -1,31 +1,62 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { HttpOptions, ProviderID, type ModelID } from "../schema"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images"
import type { OpenAIProviderOptionsInput } from "./openai-options"
import type { OpenAIOptionsInput } from "./openai-options"
import type { ProviderPackage } from "../provider-package"
export const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = ProviderOptions & {
readonly xai?: OpenAIOptionsInput
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
readonly providerOptions?: XAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images"
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { xai: { store: false } } },
})
const chatRoute = Route.make({
id: "openai-compatible-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport,
})
export const routes = [responsesRoute, chatRoute]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAIResponses.route.with({
return responsesRoute.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -33,9 +64,8 @@ const configuredResponsesRoute = (input: ModelOptions) => {
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAICompatibleChat.route.with({
return chatRoute.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -44,8 +74,8 @@ const configuredChatRoute = (input: ModelOptions) => {
export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
const image = (modelID: string | ModelID) =>
XAIImages.model({
id: modelID,
@@ -65,7 +95,15 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model = provider.model
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
-2
View File
@@ -53,9 +53,7 @@ describe("public exports", () => {
expect(CloudflareWorkersAI.configure).toBeFunction()
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
expect(OpenRouter.model).toBeFunction()
expect(OpenRouter.provider.model).toBe(OpenRouter.model)
expect(XAI.model).toBeFunction()
expect(XAI.provider.model).toBe(XAI.model)
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
@@ -3,11 +3,11 @@ import { XAI } from "../../src/providers"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: true } },
providerOptions: { xai: { reasoningEffort: true } },
})
+31
View File
@@ -21,6 +21,8 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/chat"),
import("@opencode-ai/ai/providers/google-vertex/responses"),
import("@opencode-ai/ai/providers/google-vertex/messages"),
import("@opencode-ai/ai/providers/openrouter"),
import("@opencode-ai/ai/providers/xai"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -29,6 +31,35 @@ describe("provider package entrypoints", () => {
expect(modules[12].model).toBe(modules[13].model)
})
test("maps OpenRouter and xAI package settings onto executable models", async () => {
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
const XAI = await import("@opencode-ai/ai/providers/xai")
const settings = {
apiKey: "fixture",
baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { openrouter: { usage: true } },
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { xai: { reasoningEffort: "high" } },
})
for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
}
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
})
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
@@ -129,8 +129,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
api.list().then((projects) => {
return projects
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
@@ -168,6 +168,7 @@ export function sanitizeProject(project: Project) {
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return {
...project,
worktree: "canonical" in project ? project.canonical : project.worktree,
vcs: project.vcs === "git" ? "git" : undefined,
}
}
@@ -153,4 +153,42 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
test("forgets withdrawn input before a later promotion", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "steer", data: { text: "hello" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_withdrawn",
type: "session.input.withdrawn",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
})
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.withdrawn":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
+10 -3
View File
@@ -128,7 +128,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
const located = <T>(data: T, value?: { directory?: string }) => ({
location: {
directory: directory(value) ?? "",
project: { id: "", directory: directory(value) ?? "" },
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" },
},
data,
})
@@ -298,12 +298,19 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
project: {
...input.current.project,
async list() {
return ((await legacy().project.list()).data ?? []) as Project[]
return ((await legacy().project.list()).data ?? []).map((project) => ({
...project,
canonical: project.worktree,
}))
},
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
const result = await legacy(value?.location).project.current()
if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
return {
id: result.data.id,
directory: result.data.worktree,
canonical: result.data.worktree,
} satisfies ProjectCurrent
},
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
+3
View File
@@ -40,6 +40,9 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"ws": "8.21.0"
},
+1 -1
View File
@@ -62,8 +62,8 @@ for (const target of targets) {
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await build(mainConfig(input))
await copyNodeAssets(assets)
await build(mainConfig(input))
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
+5 -1
View File
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
@@ -43,6 +43,10 @@ export async function collectNodeAssets(target: NodeTarget) {
key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
...Object.values(shellParserWasmAssets).map((key) => ({
key,
source: fileURLToPath(import.meta.resolve(key)),
})),
...attentionSoundAssets.map((key) => ({
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
-3
View File
@@ -79,9 +79,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional),
},
}),
Spec.make("logout", {
description: "Log out of OpenCode Console",
}),
],
}),
Spec.make("auth", {
@@ -1,32 +0,0 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
const integrationID = "opencode"
const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.console.commands.logout,
Effect.fn("cli.console.logout")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const found = yield* Effect.promise(() => client.integration.get({ integrationID, location }))
const credentials = found.data?.connections.filter((connection) => connection.type === "credential") ?? []
if (credentials.length === 0) {
process.stdout.write("Not logged in" + EOL)
return
}
yield* Effect.forEach(
credentials,
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
{ discard: true },
)
process.stdout.write("Logged out from OpenCode Console" + EOL)
}),
)
-1
View File
@@ -25,7 +25,6 @@ const Handlers = Runtime.handlers(Commands, {
},
console: {
login: () => import("./commands/handlers/console/login"),
logout: () => import("./commands/handlers/console/logout"),
},
mcp: {
list: () => import("./commands/handlers/mcp/list"),
+5
View File
@@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) {
}
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
export const shellParserWasmAssets = {
runtime: "web-tree-sitter/tree-sitter.wasm",
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
} as const
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
export const attentionSoundAssets = [
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: {},
body: { boundary: { type: "through" } },
})
})
-86
View File
@@ -1,86 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
const cleanup: Array<() => Promise<void>> = []
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()))
})
async function cli(args: string[], env?: Record<string, string>) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
test("registers the console logout command", async () => {
const result = await cli(["console", "--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("logout Log out of OpenCode Console")
})
test("removes stored OpenCode Console credentials", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-console-logout-"))
const removed: string[] = []
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") {
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}
if (request.method === "GET" && url.pathname === "/api/integration/opencode") {
return Response.json({
location: { directory: process.cwd(), project: { id: "global", directory: "/" } },
data: {
id: "opencode",
name: "OpenCode",
methods: [],
connections: removed.length === 0 ? [{ type: "credential", id: "cred_test", label: "default" }] : [],
},
})
}
if (request.method === "DELETE" && url.pathname === "/api/credential/cred_test") {
removed.push(url.pathname)
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
},
})
cleanup.push(async () => {
server.stop(true)
await fs.rm(root, { recursive: true, force: true })
})
await fs.mkdir(path.join(root, "state", "opencode"), { recursive: true })
await fs.writeFile(
path.join(root, "state", "opencode", "service-local.json"),
JSON.stringify({ version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid }),
)
const env = {
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
const result = await cli(["console", "logout"], env)
expect(result).toMatchObject({ exitCode: 0, stdout: "Logged out from OpenCode Console\n" })
expect(removed).toEqual(["/api/credential/cred_test"])
const repeated = await cli(["console", "logout"], env)
expect(repeated).toMatchObject({ exitCode: 0, stdout: "Not logged in\n" })
})
+1 -1
View File
@@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
function location(directory: string, workspaceID?: string): LocationGetOutput {
return { directory, workspaceID, project: { id: "project", directory } }
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
}
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
+10 -1
View File
@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
import {
nodeExecArgv,
nodeTarget,
type NodeTarget,
photonWasmAsset,
shellParserWasmAssets,
} from "./src/node/target"
const dir = import.meta.dirname
@@ -194,6 +200,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
try {
+19 -1
View File
@@ -370,6 +370,15 @@ export type Endpoint5_26Output =
readonly input: SessionPending.Message
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.withdrawn"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -841,6 +850,12 @@ export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messa
export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_30Output = boolean
export type SessionPendingWithdrawOperation<E = never> = (
input: Endpoint5_30Input,
) => Effect.Effect<Endpoint5_30Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E>
@@ -865,7 +880,10 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly withdraw: SessionPendingWithdrawOperation<E>
}
readonly instructions: {
readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E>
+11 -1
View File
@@ -76,6 +76,8 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -550,6 +552,14 @@ const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29I
),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.pending.withdraw"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw),
create: Endpoint5_1(raw),
@@ -570,7 +580,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
pending: { list: Endpoint5_21(raw), withdraw: Endpoint5_30(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
@@ -70,6 +70,8 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionPendingWithdrawInput,
SessionPendingWithdrawOutput,
MessageListInput,
MessageListOutput,
ModelListInput,
@@ -732,6 +734,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
withdraw: (input: SessionPendingWithdrawInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionPendingWithdrawOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/withdraw`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
instructions: {
entry: {
+62 -39
View File
@@ -279,7 +279,7 @@ export type ProjectCommands = { start?: string }
export type ProjectTime = { created: number; updated: number; initialized?: number }
export type ProjectCurrent = { id: string; directory: string }
export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type ProjectDirectory = { directory: string; strategy?: string }
@@ -637,6 +637,16 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string }
}
export type SessionInputWithdrawn = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.withdrawn"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = {
id: string
created: number
@@ -1430,7 +1440,7 @@ export type McpResourceCatalog = { resources: Array<McpResource>; templates: Arr
export type Project = {
id: string
worktree: string
canonical: string
vcs?: ProjectVcs
name?: string
icon?: ProjectIcon
@@ -2157,6 +2167,7 @@ export type SessionEventDurable =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputWithdrawn
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2252,6 +2263,7 @@ export type V2Event =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputWithdrawn
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2515,7 +2527,11 @@ export type LocationGetInput = {
}["location"]
}
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
export type LocationGetOutput = {
directory: string
workspaceID?: string
project: { id: string; directory: string; canonical: string }
}
export type AgentListInput = {
readonly location?: {
@@ -2524,7 +2540,7 @@ export type AgentListInput = {
}
export type AgentListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<AgentInfo>
}
@@ -2536,7 +2552,7 @@ export type AgentGetInput = {
}
export type AgentGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: AgentInfo
}
@@ -2547,7 +2563,7 @@ export type PluginListInput = {
}
export type PluginListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
@@ -3203,6 +3219,13 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionPendingWithdrawInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingWithdrawOutput = { data: boolean }["data"]
export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: {
@@ -3231,7 +3254,7 @@ export type ModelListInput = {
}
export type ModelListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ModelInfo>
}
@@ -3242,7 +3265,7 @@ export type ModelDefaultInput = {
}
export type ModelDefaultOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ModelInfo | null
}
@@ -3269,7 +3292,7 @@ export type ProviderListInput = {
}
export type ProviderListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ProviderInfo>
}
@@ -3281,7 +3304,7 @@ export type ProviderGetInput = {
}
export type ProviderGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ProviderInfo
}
@@ -3292,7 +3315,7 @@ export type IntegrationListInput = {
}
export type IntegrationListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<IntegrationInfo>
}
@@ -3304,7 +3327,7 @@ export type IntegrationGetInput = {
}
export type IntegrationGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationInfo | null
}
@@ -3351,7 +3374,7 @@ export type IntegrationOauthConnectInput = {
}
export type IntegrationOauthConnectOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: {
attemptID: string
url: string
@@ -3370,7 +3393,7 @@ export type IntegrationOauthStatusInput = {
}
export type IntegrationOauthStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationAttemptStatus
}
@@ -3405,7 +3428,7 @@ export type IntegrationCommandConnectInput = {
}
export type IntegrationCommandConnectOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationCommandAttempt
}
@@ -3418,7 +3441,7 @@ export type IntegrationCommandStatusInput = {
}
export type IntegrationCommandStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationCommandAttemptStatus
}
@@ -3439,7 +3462,7 @@ export type McpListInput = {
}
export type McpListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<McpServer>
}
@@ -3528,7 +3551,7 @@ export type McpResourceCatalogInput = {
}
export type McpResourceCatalogOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: McpResourceCatalog
}
@@ -3577,7 +3600,7 @@ export type FormRequestListInput = {
}
export type FormRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FormInfo>
}
@@ -4433,7 +4456,7 @@ export type PermissionRequestListInput = {
}
export type PermissionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PermissionRequest>
}
@@ -4555,7 +4578,7 @@ export type FileListInput = {
}
export type FileListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry>
}
@@ -4587,7 +4610,7 @@ export type FileFindInput = {
}
export type FileFindOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry>
}
@@ -4598,7 +4621,7 @@ export type CommandListInput = {
}
export type CommandListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<CommandInfo>
}
@@ -4609,7 +4632,7 @@ export type SkillListInput = {
}
export type SkillListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<SkillInfo>
}
@@ -4622,7 +4645,7 @@ export type PtyListInput = {
}
export type PtyListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<Pty>
}
@@ -4668,7 +4691,7 @@ export type PtyCreateInput = {
}
export type PtyCreateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4680,7 +4703,7 @@ export type PtyGetInput = {
}
export type PtyGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4697,7 +4720,7 @@ export type PtyUpdateInput = {
}
export type PtyUpdateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4717,7 +4740,7 @@ export type ShellListInput = {
}
export type ShellListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ShellInfo1>
}
@@ -4752,7 +4775,7 @@ export type ShellCreateInput = {
}
export type ShellCreateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4764,7 +4787,7 @@ export type ShellGetInput = {
}
export type ShellGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4777,7 +4800,7 @@ export type ShellTimeoutInput = {
}
export type ShellTimeoutOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4801,7 +4824,7 @@ export type ShellOutputInput = {
}
export type ShellOutputOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: { output: string; cursor: number; size: number; truncated: boolean }
}
@@ -4821,7 +4844,7 @@ export type QuestionRequestListInput = {
}
export type QuestionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<QuestionRequest>
}
@@ -4851,7 +4874,7 @@ export type ReferenceListInput = {
}
export type ReferenceListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ReferenceInfo>
}
@@ -4894,7 +4917,7 @@ export type VcsStatusInput = {
}
export type VcsStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<VcsFileStatus>
}
@@ -4917,7 +4940,7 @@ export type VcsDiffInput = {
}
export type VcsDiffOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileDiffInfo>
}
@@ -4938,7 +4961,7 @@ export type WebsearchProvidersInput = {
}
export type WebsearchProvidersOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<WebSearchProvider>
}
@@ -4951,6 +4974,6 @@ export type WebsearchQueryInput = {
}
export type WebsearchQueryOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: { providerID: string; results: Array<WebSearchResult> }
}
+11 -3
View File
@@ -300,7 +300,7 @@ test("session instructions methods use the public HTTP contract", async () => {
])
})
test("session.pending.list uses the public HTTP contract", async () => {
test("session.pending uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const pending = [
{
@@ -317,14 +317,22 @@ test("session.pending.list uses the public HTTP contract", async () => {
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return Response.json({ data: pending })
return Response.json({ data: request.method === "GET" ? pending : true })
},
})
const result = await client.session.pending.list({ sessionID: "ses_test" })
const withdrawn = await client.session.pending.withdraw({ sessionID: "ses_test", inputID: "msg_pending" })
expect(result).toEqual(pending)
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
expect(withdrawn).toBe(true)
expect(requests).toEqual([
{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" },
{
method: "POST",
url: "http://localhost:3000/api/session/ses_test/pending/msg_pending/withdraw",
},
])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
+8
View File
@@ -41,6 +41,11 @@
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
"#shell-parser-wasm": {
"bun": "./src/shell/parser-wasm.bun.ts",
"node": "./src/shell/parser-wasm.node.ts",
"default": "./src/shell/parser-wasm.bun.ts"
},
"#process-lock-ffi": {
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
@@ -119,7 +124,10 @@
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:"
},
+19 -5
View File
@@ -6,6 +6,7 @@ export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Entry = typeof Entry.Type
@@ -56,26 +57,39 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
if (left.path > right.path) return 1
return 0
})
const ranked = rankListings(listings)
const pinned = new Set(
namespaceEntries
.filter((entry) => entry.pinned)
.map((entry) => listings.find((listing) => listing.path === entry.path))
.filter((listing) => listing !== undefined),
)
return {
name,
listings,
selectionOrder: rankListings(listings),
selectedListings: new Set<typeof Listing.Type>(),
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: pinned,
selectionIndex: 0,
}
})
const active = new Set(namespaces)
let remaining = budget
let remaining =
budget -
namespaces
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
const candidate = namespace.selectionOrder[namespace.selectionIndex]
if (!candidate || candidate.cost > remaining) {
active.delete(namespace)
continue
}
namespace.selectedListings.add(candidate.listing)
namespace.selectionIndex += 1
remaining -= candidate.cost
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace)
}
}
+15 -6
View File
@@ -138,7 +138,14 @@ export const create = (
}
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
const pinned = new Set(
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
}
function runtime(
@@ -149,11 +156,7 @@ function runtime(
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(registration)
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const path =
registration.options?.namespace === undefined
? normalized
: `${registration.options.namespace}.${normalized}`
const path = qualifiedName(registration)
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
@@ -164,6 +167,12 @@ function runtime(
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function qualifiedName(registration: Info) {
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
if (registration.options?.namespace === undefined) return normalized
return `${registration.options.namespace}.${normalized}`
}
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
if (input === null || input === undefined) return
+3 -16
View File
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { dirname } from "path"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
export interface Target {
readonly canonical: string
@@ -108,13 +109,13 @@ const layer = Layer.effect(
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const next = splitBom(input.content)
const next = Bom.split(input.content)
const current = yield* fs
.readFile(input.target.canonical)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.canonical,
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
@@ -172,20 +173,6 @@ const layer = Layer.effect(
}),
)
function splitBom(text: string) {
const stripped = text.replace(/^\uFEFF+/, "")
return { bom: stripped.length !== text.length, text: stripped }
}
function joinBom(text: string, bom: boolean) {
const stripped = splitBom(text).text
return bom ? `\uFEFF${stripped}` : stripped
}
function hasUtf8Bom(content: Uint8Array) {
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
}
function sameBytes(left: Uint8Array, right: Uint8Array) {
if (left.length !== right.length) return false
return left.every((byte, index) => byte === right[index])
+154
View File
@@ -0,0 +1,154 @@
export * as Formatter from "./formatter"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Location } from "./location"
import { make, type Info } from "./formatter/builtins"
export const Status = Schema.Struct({
name: Schema.String,
extensions: Schema.Array(Schema.String),
enabled: Schema.Boolean,
}).annotate({ identifier: "FormatterStatus" })
export type Status = typeof Status.Type
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly file: (filepath: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Formatter") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const commands = new Map<string, string[] | false>()
let formatters: Info[] = []
const load = yield* Effect.cached(
Effect.gen(function* () {
const configured = Config.latest(yield* config.entries(), "formatter")
if (!configured) {
yield* Effect.logInfo("all formatters are disabled")
return
}
const builtIns = make({
directory: location.directory,
worktree: location.project.directory,
fs,
npm,
processes,
})
formatters = builtIns
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
const index = formatters.findIndex((formatter) => formatter.name === name)
if (entry.disabled) {
if (index !== -1) formatters.splice(index, 1)
continue
}
const builtIn = builtIns.find((formatter) => formatter.name === name)
const formatter: Info = {
name,
extensions: entry.extensions ?? builtIn?.extensions ?? [],
environment: { ...builtIn?.environment, ...entry.environment },
enabled:
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
if (index === -1) formatters.push(formatter)
else formatters[index] = formatter
}
}).pipe(Effect.withSpan("Formatter.load")),
)
const command = Effect.fnUntraced(function* (formatter: Info) {
const cached = commands.get(formatter.name)
if (cached !== undefined) return cached
const result = yield* formatter.enabled
if (result !== false) commands.set(formatter.name, result)
return result
})
const init = Effect.fn("Formatter.init")(function* () {
yield* load
})
const status = Effect.fn("Formatter.status")(function* () {
yield* load
return yield* Effect.forEach(formatters, (formatter) =>
command(formatter).pipe(
Effect.map((enabled) => ({
name: formatter.name,
extensions: [...formatter.extensions],
enabled: enabled !== false,
})),
),
)
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
yield* load
const matching = formatters.filter((formatter) =>
formatter.extensions.includes(path.extname(filepath)),
)
for (const formatter of matching) {
const enabled = yield* command(formatter)
if (enabled === false) continue
const cmd = enabled.map((argument) => argument.replace("$FILE", filepath))
yield* Effect.logInfo("formatting file", { file: filepath, command: cmd })
const result = yield* processes
.run(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: location.directory,
env: formatter.environment,
extendEnv: true,
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}),
)
.pipe(
Effect.catch((error) =>
Effect.logError("failed to format file", {
file: filepath,
command: cmd,
error: error.message,
}).pipe(Effect.as(undefined)),
),
)
if (!result) continue
if (result.exitCode === 0) return true
yield* Effect.logError("formatter exited unsuccessfully", {
file: filepath,
command: cmd,
exitCode: result.exitCode,
})
}
return false
})
return Service.of({ init, status, file })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
})
+314
View File
@@ -0,0 +1,314 @@
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { which } from "../util/which"
export interface Info {
readonly name: string
readonly environment?: Record<string, string>
readonly extensions: readonly string[]
readonly enabled: Effect.Effect<string[] | false>
}
export function make(input: {
readonly directory: string
readonly worktree: string
readonly fs: FSUtil.Interface
readonly npm: Npm.Interface
readonly processes: AppProcess.Interface
}) {
const disabled = false as const
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
const commandOutput = (command: string[]) =>
input.processes
.run(
ChildProcess.make(command[0], command.slice(1), {
cwd: input.directory,
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(Effect.option)
const gofmt: Info = {
name: "gofmt",
extensions: [".go"],
enabled: Effect.sync(() => {
const match = which("gofmt")
return match ? [match, "-w", "$FILE"] : disabled
}),
}
const mix: Info = {
name: "mix",
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
enabled: Effect.sync(() => {
const match = which("mix")
return match ? [match, "format", "$FILE"] : disabled
}),
}
const prettier: Info = {
name: "prettier",
environment: { BUN_BE_BUN: "1" },
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
enabled: Effect.gen(function* () {
for (const file of yield* findUp("package.json")) {
if (!hasDependency(yield* input.fs.readJson(file), "prettier")) continue
const bin = yield* input.npm.which("prettier")
if (bin) return [bin, "--write", "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const oxfmt: Info = {
name: "oxfmt",
environment: { BUN_BE_BUN: "1" },
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
enabled: Effect.gen(function* () {
for (const file of yield* findUp("package.json")) {
if (!hasDependency(yield* input.fs.readJson(file), "oxfmt")) continue
const bin = yield* input.npm.which("oxfmt")
if (bin) return [bin, "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const biome: Info = {
name: "biome",
environment: { BUN_BE_BUN: "1" },
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
enabled: Effect.gen(function* () {
const found = yield* Effect.forEach(["biome.json", "biome.jsonc"], findUp, { concurrency: "unbounded" })
if (!found.some((items) => items.length > 0)) return disabled
const bin = yield* input.npm.which("@biomejs/biome")
return bin ? [bin, "format", "--write", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const zig: Info = {
name: "zig",
extensions: [".zig", ".zon"],
enabled: Effect.sync(() => {
const match = which("zig")
return match ? [match, "fmt", "$FILE"] : disabled
}),
}
const clang: Info = {
name: "clang-format",
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".clang-format")).length) return disabled
const match = which("clang-format")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const ktlint: Info = {
name: "ktlint",
extensions: [".kt", ".kts"],
enabled: Effect.sync(() => {
const match = which("ktlint")
return match ? [match, "-F", "$FILE"] : disabled
}),
}
const ruff: Info = {
name: "ruff",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
if (!which("ruff")) return disabled
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
const found = yield* findUp(config)
if (!found.length) continue
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
return ["ruff", "format", "$FILE"]
}
}
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
const found = yield* findUp(dependency)
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const air: Info = {
name: "air",
extensions: [".R"],
enabled: Effect.gen(function* () {
const bin = which("air")
if (!bin) return disabled
const output = yield* commandOutput([bin, "--help"])
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
const first = output.value.stdout.toString("utf8").split("\n")[0]
return first.includes("R language") && first.includes("formatter") ? [bin, "format", "$FILE"] : disabled
}),
}
const uv: Info = {
name: "uv",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
const bin = which("uv")
if (!bin) return disabled
const output = yield* commandOutput([bin, "format", "--help"])
return output._tag === "Some" && output.value.exitCode === 0
? [bin, "format", "--", "$FILE"]
: disabled
}),
}
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
const dart = executable("dart", [".dart"], ["format", "$FILE"])
const ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".ocamlformat")).length) return disabled
const match = which("ocamlformat")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
const pint: Info = {
name: "pint",
extensions: [".php"],
enabled: Effect.gen(function* () {
for (const file of yield* findUp("composer.json")) {
const json = yield* input.fs.readJson(file)
if (hasRecordKey(json, "require", "laravel/pint") || hasRecordKey(json, "require-dev", "laravel/pint")) {
return ["./vendor/bin/pint", "$FILE"]
}
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
return [
gofmt,
mix,
oxfmt,
prettier,
biome,
zig,
clang,
ktlint,
ruff,
air,
uv,
rubocop,
standardrb,
htmlbeautifier,
dart,
ocamlformat,
terraform,
latexindent,
gleam,
shfmt,
nixfmt,
rustfmt,
pint,
ormolu,
cljfmt,
dfmt,
] satisfies Info[]
}
function executable(name: string, extensions: readonly string[], args: string[]): Info {
return {
name,
extensions,
enabled: Effect.sync(() => {
const match = which(name)
return match ? [match, ...args] : false
}),
}
}
function hasDependency(input: unknown, dependency: string) {
return hasRecordKey(input, "dependencies", dependency) || hasRecordKey(input, "devDependencies", dependency)
}
function hasRecordKey(input: unknown, field: string, key: string) {
if (!isRecord(input)) return false
return isRecord(input[field]) && key in input[field]
}
function isRecord(input: unknown): input is Record<string, unknown> {
return Boolean(input && typeof input === "object" && !Array.isArray(input))
}
+1 -1
View File
@@ -201,7 +201,7 @@ const layer = Layer.effect(
locks.withLock(repository.gitDirectory)(effect)
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
+2
View File
@@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { FileMutation } from "./file-mutation"
import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate"
@@ -73,6 +74,7 @@ const locationServiceNodes = [
InstructionDiscovery.node,
LocationMutation.node,
FileMutation.node,
Formatter.node,
MCP.node,
Permission.node,
Tool.node,
+1 -1
View File
@@ -25,7 +25,7 @@ const layer = (ref: Ref) =>
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
})
}),
+26 -3
View File
@@ -182,7 +182,8 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (Provider.isAISDK(resolved.package)) {
const native = Provider.isAISDK(resolved.package) ? nativePackage(packageName) : resolved.package
if (Provider.isAISDK(resolved.package) && !native) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
@@ -193,20 +194,22 @@ export const fromCatalogModel = (
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!resolved.package) return Effect.fail(unsupported(resolved))
if (!native) return Effect.fail(unsupported(resolved))
const specifier = resolved.package
const specifier = native
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const configured = { ...resolved.settings, ...credential?.metadata }
const providerOptions = nativeProviderOptions(packageName, configured)
const settings = {
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
...(providerOptions ? { providerOptions } : {}),
}
return yield* Effect.try({
try: () => {
@@ -223,6 +226,26 @@ export const fromCatalogModel = (
})
}
const nativePackage = (packageName: string | undefined) => {
if (packageName === "@ai-sdk/google") return "@opencode-ai/ai/providers/google"
if (packageName === "@openrouter/ai-sdk-provider") return "@opencode-ai/ai/providers/openrouter"
if (packageName === "@ai-sdk/xai") return "@opencode-ai/ai/providers/xai"
return undefined
}
const nativeProviderOptions = (packageName: string | undefined, settings: Readonly<Record<string, unknown>>) => {
const values = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
if (Object.keys(values).length === 0) return undefined
if (packageName === "@ai-sdk/google") return { gemini: values }
if (packageName === "@openrouter/ai-sdk-provider") return { openrouter: values }
if (packageName === "@ai-sdk/xai") return { xai: values }
return undefined
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
-2
View File
@@ -11,8 +11,6 @@ import { Permission } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM =
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
+2
View File
@@ -2,6 +2,7 @@ export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -10,6 +11,7 @@ import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly shell: ShellHooks
readonly tool: ToolHooks
}
+3
View File
@@ -296,6 +296,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
})
}),
},
shell: {
hook: (name, callback) => hooks.register("shell", name, callback),
},
tool: {
transform: (callback) =>
tools
+3
View File
@@ -16,6 +16,7 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { Bus } from "../bus"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { Form } from "../form"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -68,6 +69,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
@@ -98,6 +100,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Config.Service, config),
Context.make(Bus.Service, bus),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(FileSystem.Service, filesystem),
Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
+4
View File
@@ -326,6 +326,10 @@ export function fromPromise(plugin: Plugin) {
),
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
},
shell: {
hook: (name, callback) =>
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
}
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
+2
View File
@@ -14,6 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem"
import { Watcher } from "../filesystem/watcher"
import { Form } from "../form"
@@ -318,6 +319,7 @@ export const node = makeLocationNode({
Config.node,
Bus.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
+52 -8
View File
@@ -2,7 +2,7 @@ export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema"
import { Database } from "./database/database"
@@ -40,6 +40,7 @@ export interface Resolved {
readonly previous?: ID
readonly id: ID
readonly directory: AbsolutePath
readonly canonical: AbsolutePath
readonly vcs?: Vcs
}
@@ -48,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
input: AbsolutePath,
) {
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -83,7 +84,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
: undefined
return {
id: row.id,
worktree: row.worktree,
canonical: row.worktree,
vcs: row.vcs ?? undefined,
name: row.name ?? undefined,
icon,
@@ -106,6 +107,40 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service
const persist = Effect.fnUntraced(function* (project: Resolved) {
yield* db
.transaction((tx) =>
Effect.gen(function* () {
const vcs = project.vcs?.type
yield* tx
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
if (!project.vcs) return
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
if (project.directory === project.canonical) return
yield* projectDirectories.create(
{
projectID: project.id,
directory: project.directory,
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
},
tx,
)
}),
)
.pipe(Effect.orDie)
return project
})
const list = Effect.fn("Project.list")(function* () {
const rows = yield* db
.select()
@@ -189,7 +224,7 @@ const layer = Layer.effect(
})
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -211,17 +246,26 @@ const layer = Layer.effect(
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
const canonical =
repo.gitDirectory === repo.commonDirectory
? repo.worktree
: yield* git.worktree.list(repo).pipe(
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
Effect.catch(() => Effect.succeed(repo.worktree)),
)
return yield* persist({
previous,
id: id ?? ID.global,
directory: repo.worktree,
canonical,
vcs: { type: "git" as const, store: repo.commonDirectory },
}
})
}
const hg = yield* hgDiscover(input)
if (hg) return hg
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
const directory = AbsolutePath.make(path.parse(input).root)
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
+28 -13
View File
@@ -3,7 +3,7 @@ export * from "./session/schema"
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
import { Project } from "./project"
import { Workspace } from "./workspace"
import { Model } from "./model"
@@ -209,6 +209,10 @@ export interface Interface {
* unhandled compaction barriers.
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly withdraw: (input: {
sessionID: SessionSchema.ID
inputID: SessionMessage.ID
}) => Effect.Effect<boolean, NotFoundError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -325,6 +329,22 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
.pipe(Effect.orDie)
}
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
@@ -347,12 +367,7 @@ const layer = Layer.effect(
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* persistProject(project)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
@@ -451,6 +466,7 @@ const layer = Layer.effect(
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.parentID !== undefined)
conditions.push(
@@ -528,6 +544,10 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID)
}),
withdraw: Effect.fn("Session.withdraw")(function* (input) {
yield* result.get(input.sessionID)
return yield* SessionPending.withdraw(db, bus, input)
}),
log: (input) =>
Stream.unwrap(
result
@@ -732,12 +752,7 @@ const layer = Layer.effect(
)
return
const project = yield* projects.resolve(directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* persistProject(project)
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
@@ -175,6 +175,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void,
"session.input.withdrawn": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
+108 -22
View File
@@ -1,6 +1,6 @@
export * as SessionPending from "./pending"
import { and, asc, eq, or } from "drizzle-orm"
import { and, asc, eq, or, sql } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
@@ -38,10 +38,15 @@ const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
const decodeWithdrawnEvent = Schema.decodeUnknownOption(SessionEvent.InputWithdrawn.data)
const admittedEventType = Bus.versionedType(
SessionEvent.InputAdmitted.type,
SessionEvent.InputAdmitted.durable.version,
)
const withdrawnEventType = Bus.versionedType(
SessionEvent.InputWithdrawn.type,
SessionEvent.InputWithdrawn.durable.version,
)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
@@ -103,6 +108,38 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
return entry.type === "compaction" ? entry : undefined
})
const admittedFromHistory = Effect.fn("SessionPending.admittedFromHistory")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
) {
const row = yield* db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, sessionID),
eq(EventTable.type, admittedEventType),
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
if (row) {
const decoded = decodeAdmittedEvent(row.data)
if (decoded._tag !== "Some" || decoded.value.inputID !== id)
return yield* Effect.die(new LifecycleConflict({ id }))
const base = { id, sessionID, timeCreated: DateTime.makeUnsafe(row.created) }
return decoded.value.input.type === "user"
? User.make({ ...base, ...decoded.value.input })
: Synthetic.make({ ...base, ...decoded.value.input })
}
// A projected message without an admitted event in this aggregate (for
// example fork-copied history) is not a retryable admission.
return yield* Effect.die(new LifecycleConflict({ id }))
})
/**
* Reconstruct the admitted record for a pending row that was already consumed
* by promotion. The projected `session_message` row proves promotion happened;
@@ -123,27 +160,30 @@ const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(func
if (message === undefined) return undefined
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const rows = yield* db
.select()
return yield* admittedFromHistory(db, sessionID, id)
})
const wasWithdrawn = Effect.fn("SessionPending.wasWithdrawn")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
) {
const row = yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
.all()
.where(
and(
eq(EventTable.aggregate_id, sessionID),
eq(EventTable.type, withdrawnEventType),
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
for (const row of rows) {
const decoded = decodeAdmittedEvent(row.data)
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
const base = {
id,
sessionID,
timeCreated: DateTime.makeUnsafe(row.created),
}
return decoded.value.input.type === "user"
? User.make({ ...base, ...decoded.value.input })
: Synthetic.make({ ...base, ...decoded.value.input })
}
// A projected message without an admitted event in this aggregate (for
// example fork-copied history) is not a retryable admission.
return yield* Effect.die(new LifecycleConflict({ id }))
if (!row) return false
const decoded = decodeWithdrawnEvent(row.data)
return decoded._tag === "Some" && decoded.value.inputID === id
})
export const admit = Effect.fn("SessionPending.admit")(function* (
@@ -162,6 +202,8 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
}
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
if (promoted !== undefined) return promoted
if (yield* wasWithdrawn(db, request.sessionID, request.id))
return yield* admittedFromHistory(db, request.sessionID, request.id)
return yield* bus
.publish(SessionEvent.InputAdmitted, {
inputID: request.id,
@@ -309,14 +351,13 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
* message insert inside the same event transaction; the deleted row is what
* makes the table pending-only.
*/
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
const consumeInput = Effect.fn("SessionPending.consumeInput")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
@@ -329,6 +370,27 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored
})
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return yield* consumeInput(db, input)
})
export const projectWithdrawn = Effect.fn("SessionPending.projectWithdrawn")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
return yield* consumeInput(db, input)
})
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
@@ -406,6 +468,30 @@ export const equivalent = (
return false
}
export const withdraw = Effect.fn("SessionPending.withdraw")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const pending = yield* find(db, input.inputID)
if (!pending) return yield* wasWithdrawn(db, input.sessionID, input.inputID)
if (pending.sessionID !== input.sessionID || pending.type === "compaction") return false
yield* bus
.publish(SessionEvent.InputWithdrawn, input)
.pipe(
Effect.catchDefect((defect) =>
wasWithdrawn(db, input.sessionID, input.inputID).pipe(
Effect.flatMap((withdrawn) => (withdrawn ? Effect.void : Effect.die(defect))),
),
),
)
return true
}),
)
})
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
+12
View File
@@ -657,6 +657,18 @@ const layer = Layer.effectDiscard(
input: event.data.input,
timeCreated: event.created,
})
yield* db
.update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InputWithdrawn, (event) =>
SessionPending.projectWithdrawn(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
+34 -18
View File
@@ -12,6 +12,8 @@ import { Bus } from "./bus"
import { Location } from "./location"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks"
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
@@ -45,7 +47,10 @@ type Active = {
*/
export interface Interface {
readonly name: () => Effect.Effect<string>
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
readonly create: <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E, R>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
@@ -68,6 +73,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
@@ -172,24 +178,34 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
}
})
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const cwd = input.cwd ?? location.directory
const shell = yield* resolve()
const args = ShellSelect.args(shell, input.command)
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
const info: Info = {
id,
status: "running",
command: input.command,
cwd,
shell,
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
@@ -203,9 +219,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(shell, args, {
cwd,
env,
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
@@ -297,7 +313,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
)
})
yield* session.timeout(input.timeout)
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
@@ -327,7 +343,7 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node],
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
})
}
+296
View File
@@ -0,0 +1,296 @@
export * as ShellParse from "./parse"
import { Effect } from "effect"
import { fileURLToPath } from "url"
import os from "os"
import path from "path"
import type { Node } from "web-tree-sitter"
import { shellParserWasm } from "#shell-parser-wasm"
import { ShellSelect } from "./select"
type Part = { type: string; text: string }
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
const ARITY: Record<string, number> = {
cat: 1,
cd: 1,
chmod: 1,
chown: 1,
cp: 1,
echo: 1,
env: 1,
export: 1,
grep: 1,
kill: 1,
killall: 1,
ln: 1,
ls: 1,
mkdir: 1,
mv: 1,
ps: 1,
pwd: 1,
rm: 1,
rmdir: 1,
sleep: 1,
source: 1,
tail: 1,
touch: 1,
unset: 1,
which: 1,
aws: 3,
az: 3,
bazel: 2,
brew: 2,
bun: 2,
"bun run": 3,
"bun x": 3,
cargo: 2,
"cargo add": 3,
"cargo run": 3,
cdk: 2,
cf: 2,
cmake: 2,
composer: 2,
consul: 2,
"consul kv": 3,
crictl: 2,
deno: 2,
"deno task": 3,
doctl: 3,
docker: 2,
"docker builder": 3,
"docker compose": 3,
"docker container": 3,
"docker image": 3,
"docker network": 3,
"docker volume": 3,
eksctl: 2,
"eksctl create": 3,
firebase: 2,
flyctl: 2,
gcloud: 3,
gh: 3,
git: 2,
"git config": 3,
"git remote": 3,
"git stash": 3,
go: 2,
gradle: 2,
helm: 2,
heroku: 2,
hugo: 2,
ip: 2,
"ip addr": 3,
"ip link": 3,
"ip netns": 3,
"ip route": 3,
kind: 2,
"kind create": 3,
kubectl: 2,
"kubectl kustomize": 3,
"kubectl rollout": 3,
kustomize: 2,
make: 2,
mc: 2,
"mc admin": 3,
minikube: 2,
mongosh: 2,
mysql: 2,
mvn: 2,
ng: 2,
npm: 2,
"npm exec": 3,
"npm init": 3,
"npm run": 3,
"npm view": 3,
npx: 2,
nvm: 2,
nx: 2,
openssl: 2,
"openssl req": 3,
"openssl x509": 3,
pip: 2,
pipenv: 2,
pnpm: 2,
"pnpm dlx": 3,
"pnpm exec": 3,
"pnpm run": 3,
poetry: 2,
podman: 2,
"podman container": 3,
"podman image": 3,
psql: 2,
pulumi: 2,
"pulumi stack": 3,
python: 2,
pyenv: 2,
rake: 2,
rbenv: 2,
"redis-cli": 2,
rustup: 2,
serverless: 2,
sfdx: 3,
skaffold: 2,
sls: 2,
sst: 2,
swift: 2,
systemctl: 2,
terraform: 2,
"terraform workspace": 3,
tmux: 2,
turbo: 2,
ufw: 2,
vault: 2,
"vault auth": 3,
"vault kv": 3,
vercel: 2,
volta: 2,
wp: 2,
yarn: 2,
"yarn dlx": 3,
"yarn run": 3,
}
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
return yield* Effect.acquireUseRelease(
Effect.succeed(tree),
(tree) =>
Effect.sync(() =>
tree.rootNode.descendantsOfType("command").reduce(
(result, node) => {
if (!node) return result
const command = parts(node)
const tokens = command.map((part) => part.text)
if (tokens.length === 0) return result
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
if (CWD.has(name)) {
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
return result
}
result.commands.push({
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
save: `${prefix(tokens).join(" ")} *`,
})
return result
},
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
),
),
(tree) => Effect.sync(() => tree.delete()),
)
})
function parts(node: Node) {
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
const child = node.child(index)
if (!child) return []
if (child.type === "command_elements")
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
const item = child.child(itemIndex)
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
return [{ type: item.type, text: item.text }]
})
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
return []
return [{ type: child.type, text: child.text }]
})
}
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
if (!powershell)
return command
.slice(1)
.filter((part) => !part.text.startsWith("-"))
.map((part) => directoryArgument(part.text, powershell, cwd, shell))
.filter((part) => part !== undefined)
const directories: string[] = []
let path = false
for (const part of command.slice(1)) {
if (path) {
const value = directoryArgument(part.text, powershell, cwd, shell)
if (value) directories.push(value)
path = false
continue
}
if (part.type === "command_parameter") {
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
continue
}
const value = directoryArgument(part.text, powershell, cwd, shell)
if (value) directories.push(value)
}
return directories
}
function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string) {
const quote = value[0]
const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value
if (!powershell) return expandKnownDirectory(text)
// PowerShell exposes environment variables through $env:NAME and provides these
// automatic directory variables. Expand only values we can determine without executing code.
return expandKnownDirectory(
text
.replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => environment(key) ?? "")
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => environment(key) ?? "")
.replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => {
if (key.toUpperCase() === "HOME") return os.homedir()
if (key.toUpperCase() === "PWD") return cwd
return path.dirname(shell)
}),
)
}
function expandKnownDirectory(value: string) {
// Unknown shell expressions cannot be resolved safely during permission analysis.
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
if (value === "~") return os.homedir()
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
return value
}
function environment(key: string) {
if (process.platform !== "win32") return process.env[key]
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
return name ? process.env[name] : undefined
}
function prefix(tokens: string[]) {
for (let length = tokens.length; length > 0; length--) {
const arity = ARITY[tokens.slice(0, length).join(" ")]
if (arity !== undefined) return tokens.slice(0, arity)
}
return tokens.slice(0, 1)
}
function resolve(asset: string) {
if (asset.startsWith("file://")) return fileURLToPath(asset)
if (path.isAbsolute(asset)) return asset
return fileURLToPath(new URL(asset, import.meta.url))
}
const load = (() => {
let loading: ReturnType<typeof initialize> | undefined
return () => (loading ??= initialize())
})()
async function initialize() {
const { Parser, Language } = await import("web-tree-sitter")
await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
const [bashLanguage, psLanguage] = await Promise.all([
Language.load(resolve(shellParserWasm.bash)),
Language.load(resolve(shellParserWasm.powershell)),
])
const bash = new Parser()
bash.setLanguage(bashLanguage)
const ps = new Parser()
ps.setLanguage(psLanguage)
return { bash, ps }
}
@@ -0,0 +1,8 @@
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" }
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" }
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" }
export const shellParserWasm = { runtime, bash, powershell }
@@ -0,0 +1,12 @@
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
export const shellParserWasm = {
runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
bash:
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
powershell:
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
}
+32 -37
View File
@@ -9,12 +9,14 @@ export * as EditTool from "./edit"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
export const name = "edit"
@@ -99,7 +101,6 @@ const findLineOccurrences = (content: string, search: string) => {
}
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
@@ -109,6 +110,7 @@ export const Plugin = {
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
@@ -151,14 +153,6 @@ export const Plugin = {
})
}
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
const info = yield* fs.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
@@ -167,9 +161,8 @@ export const Plugin = {
if (info.type === "Directory") {
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
}
const bytes = yield* fs.readFile(target.canonical)
const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
const source = new TextDecoder().decode(bom ? bytes.slice(3) : bytes)
const original = yield* Bom.readFile(fs, target.canonical)
const source = original.text
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
@@ -183,6 +176,26 @@ export const Plugin = {
: findLineOccurrences(source, oldString)
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
const replacements = matches.length
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) =>
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const preview =
replacements > 0 && (replacements === 1 || input.replaceAll === true)
? fileDiff(target.resource, source, replaced)
: undefined
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
if (replacements === 0) {
return yield* new ToolFailure({
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
@@ -193,35 +206,17 @@ export const Plugin = {
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
})
}
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) =>
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const counts = diffLines(source, replaced).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* files.write({
target,
content: `${bom || replacementBom ? "\uFEFF" : ""}${replacementBom ? replaced.slice(1) : replaced}`,
content: Bom.join(replaced, original.bom || replacementBom),
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.canonical))
? yield* Bom.syncFile(fs, target.canonical, bom)
: (yield* Bom.readFile(fs, target.canonical)).text
return {
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, source, replaced),
status: "modified" as const,
...counts,
},
],
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
@@ -0,0 +1,23 @@
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
export function fileDiff(
file: string,
before: string,
after: string,
status: typeof FileDiff.Info.Type.status = "modified",
): typeof FileDiff.Info.Type {
const counts = diffLines(before, after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
file,
patch: createTwoFilesPatch(file, file, before, after),
status,
...counts,
}
}
+47 -21
View File
@@ -7,7 +7,9 @@ import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
@@ -68,6 +70,7 @@ export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
@@ -129,15 +132,16 @@ export const Plugin = {
...hunk,
target,
before: "",
after: (hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`
).replace(/^\uFEFF/, ""),
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`,
).text,
})
return
}
if (hunk.type === "delete") {
const content = yield* fs.readFile(target.canonical).pipe(
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -145,8 +149,7 @@ export const Plugin = {
}),
),
)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
prepared.push({ ...hunk, target, before: content.text, after: "" })
return
}
const previous = updates.get(target.canonical)
@@ -166,18 +169,17 @@ export const Plugin = {
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
})
}
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(
yield* fs.readFile(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
)
return Bom.join(content.text, content.bom)
}))
const before = original.replace(/^\uFEFF/, "")
const before = Bom.split(original).text
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) =>
@@ -217,7 +219,7 @@ export const Plugin = {
)
}
const patchFiles = prepared.map(patchFile)
const patchFiles = prepared.map((change) => patchFile(change))
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
@@ -295,7 +297,31 @@ export const Plugin = {
}),
{ discard: true },
)
return { applied, files: patchFiles }
const formatted = new Map<string, string>()
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
const files = yield* Effect.forEach(prepared, (change) => {
if (change.type === "delete") return Effect.succeed(patchFile(change))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
})
return { applied, files }
}).pipe(
Effect.map((output) => ({
output,
@@ -337,15 +363,15 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after),
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
)
const counts =
change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length }
: diffLines(change.before, change.after).reduce(
: diffLines(change.before, after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
+51 -45
View File
@@ -1,5 +1,6 @@
export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
@@ -11,6 +12,7 @@ import { PluginRuntime } from "../../plugin/runtime"
import { NonNegativeInt } from "../../schema"
import { SessionSchema } from "../../session/schema"
import { Shell } from "../../shell"
import { ShellParse } from "../../shell/parse"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@@ -75,18 +77,6 @@ const modelOutput = (output: Output): string | undefined => {
return `Command exited with code ${output.exit}.`
}
/**
* Minimal core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core.
*/
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals.
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
// TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
export const Plugin = {
id: "opencode.tool.shell",
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
@@ -146,34 +136,50 @@ export const Plugin = {
messageID: context.messageID,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
let finalTimeout = timeout
const info = yield* shell.create(
{
command: input.command,
cwd: input.workdir,
timeout,
metadata: { sessionID: context.sessionID },
},
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
)
invocation.cwd = target.canonical
finalTimeout = invocation.timeout
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
}),
)
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
@@ -192,20 +198,20 @@ export const Plugin = {
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: capture.truncated,
timeout: true,
status: "completed" as const,
}
}
const capture = yield* captureShell()
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
@@ -223,14 +229,14 @@ export const Plugin = {
const job = yield* runtime.job.start({
id: context.callID,
type: name,
title: input.command,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -244,7 +250,7 @@ export const Plugin = {
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
+10 -5
View File
@@ -13,15 +13,19 @@ export const name = "subagent"
const NO_TEXT = "Subagent completed without a text response."
const backgroundStarted = (sessionID: SessionSchema.ID) =>
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.`
[
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n")
export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
}),
})
@@ -31,7 +35,8 @@ export const Output = Schema.Struct({
output: Schema.String,
})
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
"Spawns an agent in a child session to work on the specified task.",
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
"Foreground (default) runs the subagent to completion and returns its final response.",
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
"Use background only for independent work that can run while you continue elsewhere.",
+23 -5
View File
@@ -9,17 +9,20 @@ export * as WriteTool from "./write"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
export const name = "write"
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
description: "Path to the file to write to",
}),
content: Schema.String.annotate({ description: "Content to write to the file" }),
})
@@ -36,7 +39,6 @@ export const toModelOutput = (output: Output) =>
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
/** Deferred write UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
@@ -46,6 +48,8 @@ export const Plugin = {
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -55,7 +59,7 @@ export const Plugin = {
name,
options: { codemode: false, permission: "edit" },
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
input: Input,
output: Output,
execute: (input, context) =>
@@ -74,15 +78,29 @@ export const Plugin = {
agent: context.agent,
source,
})
const current = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
)
const next = Bom.split(input.content)
const preview = fileDiff(
target.resource,
current?.text ?? "",
next.text,
current ? "modified" : "added",
)
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: { files: [preview] },
sessionID: context.sessionID,
agent: context.agent,
source,
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
+2
View File
@@ -16,6 +16,7 @@ describe("CodeMode", () => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
options: { pinned: true },
execute: ({ text }) => Effect.succeed({ output: text }),
}),
)
@@ -27,6 +28,7 @@ describe("CodeMode", () => {
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
])
}).pipe(
+26 -1
View File
@@ -2,10 +2,11 @@ import { describe, expect, test } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
pinned,
})
const lookup = entry(
@@ -46,6 +47,30 @@ describe("CodeModeCatalog.summarize", () => {
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
})
test("always retains pinned tools beyond the inline budget", () => {
const pinned = [
entry("alpha.first", "First", undefined, true),
entry("beta.second", "Second", undefined, true),
]
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
expect(catalog.shown).toBe(2)
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
"alpha.first",
"beta.second",
])
})
test("spends the budget remaining after pinned tools on unpinned tools", () => {
const pinned = entry("alpha.pinned", "Pinned", undefined, true)
const unpinned = entry("beta.unpinned", "Unpinned")
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
})
test("retains only the rendered portion of inline descriptions", () => {
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
@@ -56,7 +56,7 @@ describe("node build", () => {
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: service.directory },
project: { id: Project.ID.global, directory: service.directory, canonical: service.directory },
}),
),
{ idleTimeToLive: "1 minute" },
@@ -79,7 +79,7 @@ describe("node build", () => {
return Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
commit: () => Effect.void,
})
}),
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"
import { Effect, FileSystem } from "effect"
import { Effect, FileSystem, Layer } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -267,6 +267,33 @@ describe("FSUtil", () => {
expect(result).toContain(path.join(tmp, "b.txt"))
}),
)
it(
"stops at the first match when requested",
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
const child = path.join(tmp, "sub")
yield* filesys.makeDirectory(child)
yield* filesys.writeFileString(path.join(child, "marker"), "child")
const checked: string[] = []
const instrumented = FileSystem.FileSystem.of({
...filesys,
exists: (target) => Effect.sync(() => checked.push(target)).pipe(Effect.andThen(filesys.exists(target))),
})
const search = yield* FSUtil.Service.pipe(
Effect.provide(
FSUtil.layer.pipe(Layer.fresh, Layer.provide(Layer.succeed(FileSystem.FileSystem, instrumented))),
),
)
expect(yield* search.up({ targets: ["marker", "other"], start: child, mode: "first" })).toEqual([
path.join(child, "marker"),
])
expect(checked).toEqual([path.join(child, "marker")])
}),
)
})
describe("glob", () => {
+2 -1
View File
@@ -5,10 +5,11 @@ import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
const directory = input.projectDirectory ?? ref.directory
return {
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
project: { id: Project.ID.global, directory, canonical: directory },
vcs: input.vcs,
} satisfies Location.Interface
}
+199
View File
@@ -0,0 +1,199 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Npm } from "@opencode-ai/util/npm"
import { Config } from "../src/config"
import { Formatter } from "../src/formatter"
import { Location } from "../src/location"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
type ConfigInput = typeof Config.Info.Encoded
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
const entries =
configured === undefined
? []
: [
new Config.Document({
type: "document",
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
}),
]
return AppNodeBuilder.build(Formatter.node, [
[
Config.node,
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed(entries),
changes: () => Stream.empty,
}),
),
],
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
),
],
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
])
}
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => body(tmp.path),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
}
describe("Formatter", () => {
it.live("status() returns empty list when no formatters are configured", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
),
)
it.live("status() returns built-in formatters when formatter is true", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt?.extensions).toContain(".go")
}),
).pipe(Effect.provide(formatterLayer(directory, true))),
),
)
it.live("status() keeps built-in formatters when config object is provided", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
),
)
it.live("status() excludes formatters marked as disabled in config", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
),
)
it.live("service initializes without error", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
),
)
it.live("file() returns false when no formatter runs", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.txt")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(Effect.provide(formatterLayer(directory, false))),
),
)
it.live("status() initializes formatter state per directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([off, on]) =>
Effect.gen(function* () {
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(off.path, false)),
)
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(on.path, true)),
)
expect(disabled).toEqual([])
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
}),
(directories) =>
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
),
)
it.live("stops after the first matching formatter succeeds", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.seq")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".seq"],
},
}),
),
),
),
)
it.live("tries the next matching formatter when the first fails", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.fallback")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
extensions: [".fallback"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".fallback"],
},
}),
),
),
),
)
})
+1 -1
View File
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
const selected = Info.make({
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
package: Provider.aisdk("@ai-sdk/google"),
package: Provider.aisdk("@ai-sdk/mistral"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
+2
View File
@@ -18,6 +18,7 @@ const projectLayer = Layer.succeed(
Effect.succeed({
id: Project.ID.make("project"),
directory: AbsolutePath.make("/repo"),
canonical: AbsolutePath.make("/main/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
}),
commit: () => Effect.void,
@@ -34,6 +35,7 @@ describe("Location", () => {
expect(location.workspaceID).toBe(workspaceID)
expect(location.project.id).toBe(Project.ID.make("project"))
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
expect(location.project.canonical).toBe(AbsolutePath.make("/main/repo"))
expect(location.vcs).toEqual({
type: "git",
store: AbsolutePath.make("/repo/.git"),
+37
View File
@@ -1,6 +1,7 @@
import path from "node:path"
import { describe, expect, test } from "bun:test"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
@@ -559,6 +560,42 @@ test("lists, reads, and reports MCP resource changes", async () => {
)
})
test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
let requests = 0
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
fetch: async () => {
requests += 1
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
controller.enqueue(
new TextEncoder().encode(
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
),
)
controller.close()
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
)
},
reconnectionOptions: {
initialReconnectionDelay: 1,
maxReconnectionDelay: 1,
reconnectionDelayGrowFactor: 1,
maxRetries: 2,
},
})
await transport.start()
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
await Bun.sleep(25)
await transport.close()
expect(requests).toBe(1)
})
test("skips MCP resource requests when the capability is absent", async () => {
await Effect.runPromise(
Effect.scoped(
+81 -11
View File
@@ -546,6 +546,76 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes supported AISDK catalog packages through native provider packages", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const packages = [
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "gemini"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"],
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk(catalogPackage), {
modelID: "api-model",
settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" },
headers: { "x-provider": "header" },
body: { custom: true },
}),
Credential.Key.make({ type: "key", key: "secret" }),
{
loadPackage: (specifier) => {
expect(specifier).toBe(nativePackage)
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("api-model")
expect(settings).toMatchObject({
apiKey: "secret",
baseURL: "https://provider.example/v1",
headers: { "x-provider": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
providerOptions: { [optionKey]: { reasoningEffort: "high" } },
})
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
},
})
},
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
},
),
)
}),
)
it.effect("loads supported AISDK catalog packages as native routes", () =>
Effect.gen(function* () {
const google = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }),
)
const openrouter = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
settings: { reasoning: { effort: "high" } },
}),
)
const xai = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
)
expect(google.route.id).toBe("gemini")
expect(google.route.defaults.providerOptions).toEqual({
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
})
expect(openrouter.route.id).toBe("openrouter")
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } })
expect(xai.route.id).toBe("openai-responses")
expect(xai.route.defaults.providerOptions).toEqual({
xai: { reasoningEffort: "high", store: false },
})
}),
)
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
@@ -554,8 +624,8 @@ describe("ModelResolver", () => {
}),
)
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
model(Provider.aisdk("@ai-sdk/mistral"), {
modelID: "mistral-api-model",
settings: { project: "test" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -566,9 +636,9 @@ describe("ModelResolver", () => {
Effect.sync(() => {
expect(runtime).toMatchObject({
id: "test-model",
modelID: "gemini-api-model",
modelID: "mistral-api-model",
providerID: "test-provider",
package: Provider.aisdk("@ai-sdk/google"),
package: Provider.aisdk("@ai-sdk/mistral"),
settings: { project: "test", apiKey: "fallback-secret" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -582,15 +652,15 @@ describe("ModelResolver", () => {
},
)
expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
}),
)
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example/v1" },
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://mistral.example/v1" },
}),
).pipe(Effect.flip)
@@ -598,9 +668,9 @@ describe("ModelResolver", () => {
_tag: "SessionRunnerModel.UnsupportedPackageError",
providerID: "test-provider",
modelID: "test-model",
package: "aisdk:@ai-sdk/google",
package: "aisdk:@ai-sdk/mistral",
})
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
}),
)
@@ -612,8 +682,8 @@ describe("ModelResolver", () => {
}),
)
yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
}),
undefined,
{
-21
View File
@@ -11,7 +11,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -88,11 +87,6 @@ describe("MoveSession", () => {
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
@@ -144,11 +138,6 @@ describe("MoveSession", () => {
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
@@ -204,11 +193,6 @@ describe("MoveSession", () => {
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
const sessionID = Session.ID.make("ses_move_project")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
@@ -268,11 +252,6 @@ describe("MoveSession", () => {
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested_checkout")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
+21
View File
@@ -42,4 +42,25 @@ describe("PluginHooks", () => {
expect(event.messages).toEqual([Message.user("changed")])
}),
)
it.effect("mutates shell creation input", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* hooks.register("shell", "create.before", (event) =>
Effect.sync(() => {
event.command = "echo changed"
}),
)
const event = {
command: "echo original",
cwd: "/tmp",
timeout: 0,
shell: "/bin/sh",
env: {},
}
expect(yield* hooks.trigger("shell", "create.before", event)).toBe(event)
expect(event.command).toBe("echo changed")
}),
)
})
+18 -3
View File
@@ -86,6 +86,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused skill.transform"),
reload: () => Effect.die("unused skill.reload"),
},
shell: overrides.shell ?? {
hook: () => Effect.die("unused shell.hook"),
},
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"),
@@ -118,7 +121,11 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
? Effect.succeed({
location: new Location.Info({
directory: AbsolutePath.make("/"),
project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") },
project: {
id: Project.ID.make("test"),
directory: AbsolutePath.make("/"),
canonical: AbsolutePath.make("/"),
},
}),
data: agentInfo(value),
})
@@ -160,7 +167,11 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
Effect.map((data) => ({
location: new Location.Info({
directory: AbsolutePath.make("/"),
project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") },
project: {
id: Project.ID.make("test"),
directory: AbsolutePath.make("/"),
canonical: AbsolutePath.make("/"),
},
}),
data: data.map(modelInfo),
})),
@@ -354,7 +365,11 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
const location = Location.Info.make({
directory: AbsolutePath.make("/tmp/websearch-test"),
project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") },
project: {
id: Project.ID.make("websearch-test"),
directory: AbsolutePath.make("/tmp/websearch-test"),
canonical: AbsolutePath.make("/tmp/websearch-test"),
},
})
return {
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
+39 -3
View File
@@ -47,13 +47,13 @@ describe("Project.list", () => {
expect(yield* project.list()).toEqual([
{
id: Project.ID.make("newer"),
worktree: abs("/newer"),
canonical: abs("/newer"),
time: { created: 2, updated: 2, initialized: 3 },
sandboxes: [],
},
{
id: Project.ID.make("older"),
worktree: abs("/older"),
canonical: abs("/older"),
vcs: "git",
name: "Older",
icon: { color: "#000000" },
@@ -105,6 +105,7 @@ describe("Project.resolve", () => {
expect(result.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
expect(result.canonical).toBe(result.directory)
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),
@@ -123,6 +124,7 @@ describe("Project.resolve", () => {
expect(result.id).toBe(Project.ID.make("global"))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.canonical).toBe(result.directory)
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
@@ -141,6 +143,7 @@ describe("Project.resolve", () => {
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.canonical).toBe(result.directory)
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
@@ -327,13 +330,46 @@ describe("Project.resolve", () => {
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
const project = yield* Project.Service
const db = (yield* Database.Service).db
const id = remoteID("github.com/owner/repo")
yield* db
.insert(ProjectTable)
.values({
id,
worktree: abs("/stale-worktree"),
vcs: "hg",
name: "Preserved name",
icon_color: "#123456",
commands: { start: "bun dev" },
sandboxes: [abs("/preserved-sandbox")],
time_created: 1,
time_updated: 1,
time_initialized: 2,
})
.run()
const result = yield* project.resolve(abs(worktree))
expect(result.directory).toBe(yield* real(worktree))
expect(result.canonical).toBe(yield* real(tmp.path))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.id).toBe(id)
expect(result.vcs?.type).toBe("git")
expect((yield* project.list()).find((item) => item.id === id)).toMatchObject({
canonical: yield* real(tmp.path),
vcs: "git",
name: "Preserved name",
icon: { color: "#123456" },
commands: { start: "bun dev" },
sandboxes: [abs("/preserved-sandbox")],
time: { created: 1, initialized: 2 },
})
expect(
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
).toEqual([
{ directory: yield* real(tmp.path) },
{ directory: yield* real(worktree), strategy: "git_worktree" },
])
}),
)
})
+1 -1
View File
@@ -34,7 +34,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
+46 -2
View File
@@ -14,7 +14,7 @@ import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
@@ -32,7 +32,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
@@ -171,6 +171,30 @@ describe("Session.create", () => {
}),
)
it.effect("orders sessions by their latest prompt", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const active = yield* session.create({ location, title: "active" })
const newer = yield* session.create({ location, title: "newer" })
yield* db
.update(SessionTable)
.set({ time_created: -2, time_updated: -2 })
.where(eq(SessionTable.id, active.id))
.run()
yield* db
.update(SessionTable)
.set({ time_created: -1, time_updated: -1 })
.where(eq(SessionTable.id, newer.id))
.run()
yield* session.prompt({ sessionID: active.id, text: "continue", resume: false })
expect((yield* session.list()).data.map((item) => item.id)).toEqual([active.id, newer.id])
}),
)
it.effect("filters direct child sessions by parent ID", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -184,6 +208,26 @@ describe("Session.create", () => {
}),
)
it.effect("filters project sessions by subpath", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const root = yield* session.create({ location, title: "root" })
const nested = yield* session.create({ location, title: "nested" })
yield* db.update(SessionTable).set({ path: "packages/tui" }).where(eq(SessionTable.id, nested.id)).run()
const page = yield* session.list({
project: Project.ID.global,
subpath: RelativePath.make("packages/tui"),
parentID: null,
})
expect(page.data.map((item) => item.id)).toEqual([nested.id])
expect(page.data.map((item) => item.id)).not.toContain(root.id)
}),
)
it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -15,7 +15,6 @@ import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { ID } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Provider } from "@opencode-ai/core/provider"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -191,11 +190,6 @@ const setup = Effect.gen(function* () {
agent.mode = "primary"
}),
)
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
@@ -54,7 +54,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
+1 -1
View File
@@ -21,7 +21,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
+46
View File
@@ -996,6 +996,52 @@ describe("Session.pending", () => {
}),
)
it.effect("withdraws an interrupted input before promotion without resurrecting exact retries", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const admitted = yield* session.prompt({
id: SessionMessage.ID.make("msg_withdrawn"),
sessionID,
text: "Withdraw me",
resume: false,
})
yield* session.interrupt(sessionID)
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true)
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true)
const retried = yield* session.prompt({
id: admitted.id,
sessionID,
text: "Withdraw me",
resume: false,
})
expect(retried.id).toBe(admitted.id)
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputWithdrawn.type, 1))).toBe(1)
}),
)
it.effect("leaves promoted input for revert when withdrawal loses the race", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const admitted = yield* session.prompt({ sessionID, text: "Promote me", resume: false })
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(false)
expect(yield* session.messages({ sessionID })).toMatchObject([{ id: admitted.id, type: "user" }])
}),
)
it.effect("lists an unhandled compaction barrier until it settles", () =>
Effect.gen(function* () {
yield* setup
+1 -1
View File
@@ -17,7 +17,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
@@ -67,7 +67,7 @@ const transform = (
) =>
service.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
draft.add({ ...tool, name, options: options ?? tool.options }),
),
)
+1 -1
View File
@@ -231,7 +231,7 @@ const permission = Layer.succeed(
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
draft.add({ ...tool, name, options: options ?? tool.options }),
),
)
const echo = Layer.effectDiscard(
+1 -1
View File
@@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const skills = Layer.mock(Skill.Service, {
list: () =>
+1 -1
View File
@@ -17,7 +17,7 @@ import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const awaited: Session.ID[] = []
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const execution = Layer.mock(SessionExecution.Service, {
awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import os from "os"
import path from "path"
import { ShellParse } from "@opencode-ai/core/shell/parse"
describe("ShellParse", () => {
test("splits bash commands and derives reusable prefixes", async () => {
const result = await Effect.runPromise(
ShellParse.scan("git status && npm run test -- --watch", "/bin/bash", "/workspace"),
)
expect(result).toEqual({
commands: [
{ resource: "git status", save: "git status *" },
{ resource: "npm run test -- --watch", save: "npm run test *" },
],
directories: [],
})
})
test("splits PowerShell commands case-insensitively", async () => {
const result = await Effect.runPromise(
ShellParse.scan("Get-ChildItem; Write-Output 'done'", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "C:\\workspace"),
)
expect(result.commands).toEqual([
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
{ resource: "Write-Output 'done'", save: "Write-Output *" },
])
})
test("does not permission directory changes separately", async () => {
const result = await Effect.runPromise(ShellParse.scan("cd 'src dir' && git status", "/bin/bash", "/workspace"))
expect(result).toEqual({
commands: [{ resource: "git status", save: "git status *" }],
directories: ["src dir"],
})
})
test("extracts PowerShell directory parameters", async () => {
const result = await Effect.runPromise(
ShellParse.scan("Set-Location -LiteralPath '..\\outside'; Get-ChildItem", "pwsh", "C:\\workspace"),
)
expect(result.directories).toEqual(["..\\outside"])
})
test("expands deterministic directory variables", async () => {
const bash = await Effect.runPromise(ShellParse.scan("cd ~/src", "/bin/bash", "/workspace"))
expect(bash.directories).toEqual([path.join(os.homedir(), "src")])
const powershell = await Effect.runPromise(
ShellParse.scan('Set-Location "$PWD/src"; Set-Location $PSHOME', "/usr/local/bin/pwsh", "/workspace"),
)
expect(powershell.directories).toEqual(["/workspace/src", "/usr/local/bin"])
})
})
+61 -4
View File
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
@@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, FSUtil.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_edit_tool_test")
@@ -31,6 +32,7 @@ const writes: string[] = []
let reads = 0
let denyAction: string | undefined
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
const permission = Layer.succeed(
Permission.Service,
@@ -57,12 +59,17 @@ const permission = Layer.succeed(
}),
)
const formatter = Layer.mock(Formatter.Service, {
file: (target) => formatFile(target),
})
const reset = () => {
assertions.length = 0
writes.length = 0
reads = 0
denyAction = undefined
afterRead = () => Effect.void
formatFile = () => Effect.succeed(false)
}
const filesystem = Layer.effect(
@@ -109,6 +116,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
],
),
@@ -171,6 +179,17 @@ describe("EditTool", () => {
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "hello.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
],
})
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
}),
),
@@ -181,6 +200,39 @@ describe("EditTool", () => {
),
)
it.live("returns the diff for final formatted content", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "formatted.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER"))
return true
})
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call({ path: "formatted.txt", oldString: "before", newString: "after" }),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER")
expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n")
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("accepts an absolute file path inside the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -302,7 +354,7 @@ describe("EditTool", () => {
error: { type: "permission.rejected", message: "Permission denied: edit" },
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(reads).toBe(0)
expect(reads).toBe(1)
expect(writes).toEqual([])
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
}),
@@ -313,7 +365,7 @@ describe("EditTool", () => {
),
)
it.live("denied edit reads no target content and does not disclose whether oldString matches", () =>
it.live("denied edit does not disclose whether oldString matches", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -339,7 +391,7 @@ describe("EditTool", () => {
})
expect(missing).toEqual(matching)
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
expect(reads).toBe(0)
expect(reads).toBe(2)
expect(writes).toEqual([])
}),
),
@@ -574,6 +626,11 @@ describe("EditTool", () => {
(tmp) => {
reset()
const target = path.join(tmp.path, "windows.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
return true
})
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
+36 -1
View File
@@ -6,6 +6,7 @@ import { systemError } from "effect/PlatformError"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -21,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, FSUtil.node, Location.node, Permission.node],
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -33,6 +34,7 @@ let failWriteTarget: string | undefined
let readsBeforeEditApproval = 0
let editApproved = false
let afterEditApproval = (): Effect.Effect<void> => Effect.void
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
const permission = Layer.succeed(
Permission.Service,
@@ -63,6 +65,10 @@ const permission = Layer.succeed(
}),
)
const formatter = Layer.mock(Formatter.Service, {
file: (target) => formatFile(target),
})
const reset = () => {
assertions.length = 0
denyAction = undefined
@@ -72,6 +78,7 @@ const reset = () => {
readsBeforeEditApproval = 0
editApproved = false
afterEditApproval = () => Effect.void
formatFile = () => Effect.succeed(false)
}
const filesystem = Layer.effect(
@@ -135,6 +142,7 @@ const withTool = <A, E, R>(
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
]),
),
@@ -254,6 +262,28 @@ describe("PatchTool", () => {
),
)
it.live("returns file diffs for final formatted content", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "formatted.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("created", "FORMATTED"))
return true
})
return Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: formatted.txt\n+created\n*** End Patch"),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output.files[0]?.patch).toContain("+FORMATTED")
expect(settled.metadata?.files?.[0]?.patch).toContain("+FORMATTED")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMATTED\n")
})
}),
)
it.live("moves and updates a file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -552,6 +582,11 @@ describe("PatchTool", () => {
const bom = "\uFEFF"
const target = path.join(directory, "example.cs")
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
return true
})
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
+88 -9
View File
@@ -1,5 +1,6 @@
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import os from "os"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
@@ -157,6 +158,9 @@ const mixedOutputCommand = isWindows
? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100"
: "printf stdout; sleep 0.05; printf stderr >&2"
const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const timeoutOutputCommand = isWindows
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
: "printf 'before timeout'; sleep 60"
const steadyProgressCommand = isWindows
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
: "printf steady; sleep 3.4"
@@ -219,7 +223,10 @@ describe("ShellTool", () => {
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }])
expect(assertions).toMatchObject([
{ sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
}),
)
},
@@ -250,6 +257,29 @@ describe("ShellTool", () => {
),
)
it.live("permissions compound commands separately", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("captures stderr-only and mixed stdout/stderr output", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -322,6 +352,51 @@ describe("ShellTool", () => {
),
)
it.live("approves an external directory used by a directory-change command", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
return withSession(active.path, (registry) => executeTool(registry, call({ command }, "call-external-cd"))).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves an expanded external home directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]?.resources[0]).toStartWith(os.homedir().replaceAll("\\", "/"))
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("does not execute after external-directory or shell denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
@@ -461,14 +536,18 @@ describe("ShellTool", () => {
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: idleCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.content?.[1]).toMatchObject({
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining("before timeout"),
})
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
+60 -1
View File
@@ -3,6 +3,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -22,12 +23,13 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
const assertions: Permission.AssertInput[] = []
const writes: string[] = []
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
let denyAction: string | undefined
const permission = Layer.succeed(
@@ -55,9 +57,14 @@ const permission = Layer.succeed(
}),
)
const formatter = Layer.mock(Formatter.Service, {
file: (target) => formatFile(target),
})
const reset = () => {
assertions.length = 0
writes.length = 0
formatFile = () => Effect.succeed(false)
denyAction = undefined
}
@@ -93,6 +100,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
],
),
@@ -132,6 +140,17 @@ describe("WriteTool", () => {
"created",
)
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
],
})
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
}),
)
@@ -140,6 +159,30 @@ describe("WriteTool", () => {
),
)
it.live("formats the committed file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "formatted.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
return true
})
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
status: "completed",
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("overwrites a relative existing file and reports that it wrote the file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -155,6 +198,17 @@ describe("WriteTool", () => {
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "existing.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringMatching(/-before[\s\S]*\+after/),
},
],
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
@@ -174,6 +228,11 @@ describe("WriteTool", () => {
reset()
const preserved = path.join(tmp.path, "preserved.txt")
const deduplicated = path.join(tmp.path, "deduplicated.txt")
formatFile = (target) =>
Effect.promise(async () => {
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
return true
})
return Effect.promise(() =>
Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
).pipe(
+2
View File
@@ -10,6 +10,7 @@ import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
@@ -26,6 +27,7 @@ export interface Context {
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
+17
View File
@@ -0,0 +1,17 @@
import type { Hooks } from "./registration.js"
export interface ShellCreateBefore {
command: string
cwd: string
timeout: number
shell: string
env: Record<string, string | undefined>
}
export interface ShellHooks {
readonly "create.before": ShellCreateBefore
}
export interface ShellDomain {
readonly hook: Hooks<ShellHooks>
}
+2
View File
@@ -9,6 +9,7 @@ import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
@@ -25,6 +26,7 @@ export interface Context {
readonly plugin: PluginApi
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
+17
View File
@@ -0,0 +1,17 @@
import type { Hooks } from "./registration.js"
export interface ShellCreateBefore {
command: string
cwd: string
timeout: number
shell: string
env: Record<string, string | undefined>
}
export interface ShellHooks {
readonly "create.before": ShellCreateBefore
}
export interface ShellDomain {
readonly hook: Hooks<ShellHooks>
}
+24
View File
@@ -11,6 +11,7 @@ import type {
OpenCodeEvent,
PermissionSavedInfo,
PermissionRequest,
Project,
ProviderInfo,
ReferenceInfo,
SessionInfo,
@@ -77,6 +78,10 @@ export interface Data {
}
}
readonly project: {
list(): Project[]
get(projectID: string): Project | undefined
sync(): Promise<void>
invalidate(): void
readonly permission: {
list(projectID: string): PermissionSavedInfo[] | undefined
sync(projectID: string): Promise<void>
@@ -346,6 +351,25 @@ export interface UI {
navigate(destination: Destination): void
current(): Route
}
readonly tabs: {
/** Returns whether session tabs are enabled for this TUI. */
enabled(): boolean
/** Returns the currently open root-session tabs. Reactive when read in a Solid computation. */
list(): readonly {
readonly sessionID: string
readonly title?: string
readonly active: boolean
readonly busy: boolean
readonly attention: boolean
readonly unread?: "activity" | "error"
}[]
/** Opens (or focuses) a tab for a session, adding it when not already open. Returns false when tabs are disabled. */
open(sessionID: string): boolean
/** Focuses an already-open tab and returns false when it is not open. */
focus(sessionID: string): boolean
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
close(sessionID?: string): boolean
}
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
}
+16
View File
@@ -628,6 +628,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.withdraw", "/api/session/:sessionID/pending/:inputID/withdraw", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: Schema.Struct({ data: Schema.Boolean }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.withdraw",
summary: "Withdraw pending session input",
description:
"Withdraw one admitted input before promotion. Returns true when the input was withdrawn or had already been withdrawn, and false when it is no longer pending so callers can fall back to reverting projected history.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
+1
View File
@@ -17,6 +17,7 @@ export class Info extends Schema.Class<Info>("Location.Info")({
project: Schema.Struct({
id: ProjectID,
directory: AbsolutePath,
canonical: AbsolutePath,
}),
}) {}
+2 -1
View File
@@ -12,6 +12,7 @@ export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Projec
export const Current = Schema.Struct({
id: ID,
directory: AbsolutePath,
canonical: AbsolutePath,
}).annotate({ identifier: "Project.Current" })
export interface Current extends Schema.Schema.Type<typeof Current> {}
export const Directory = Schema.Struct({
@@ -46,7 +47,7 @@ export interface Time extends Schema.Schema.Type<typeof Time> {}
export const Info = Schema.Struct({
id: ID,
worktree: Schema.String,
canonical: AbsolutePath,
vcs: optional(Vcs),
name: optional(Schema.String),
icon: optional(Icon),
+11
View File
@@ -155,6 +155,16 @@ export const InputAdmitted = Event.durable({
})
export type InputAdmitted = typeof InputAdmitted.Type
export const InputWithdrawn = Event.durable({
type: "session.input.withdrawn",
...options,
schema: {
...Base,
inputID: SessionMessage.ID,
},
})
export type InputWithdrawn = typeof InputWithdrawn.Type
export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type
@@ -556,6 +566,7 @@ export const Definitions = Event.inventory(
Forked,
InputPromoted,
InputAdmitted,
InputWithdrawn,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
+13 -2
View File
@@ -19,12 +19,23 @@ export interface Context {
readonly progress: (update: Metadata) => Effect.Effect<void>
}
export interface Options {
interface BaseOptions {
readonly namespace?: string
readonly codemode?: boolean
readonly permission?: string
}
export type Options = BaseOptions &
(
| {
readonly codemode?: true
readonly pinned?: boolean
}
| {
readonly codemode: boolean
readonly pinned?: never
}
)
export type ValueSchema<A = unknown> =
| Schema.Codec<A, any>
| (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>)
@@ -108,6 +108,7 @@ describe("public event manifest", () => {
"session.forked.2",
"session.input.promoted.1",
"session.input.admitted.1",
"session.input.withdrawn.1",
"session.execution.started.1",
"session.execution.succeeded.1",
"session.execution.failed.1",
+5 -1
View File
@@ -9,7 +9,11 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
.handle("project.list", () => Project.Service.use((project) => project.list()))
.handle("project.current", () =>
Location.Service.use((location) =>
Effect.succeed({ id: location.project.id, directory: location.project.directory }),
Effect.succeed({
id: location.project.id,
directory: location.project.directory,
canonical: location.project.canonical,
}),
),
)
.handle("project.directories", (ctx) =>

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