Compare commits

..
350 changed files with 11171 additions and 5282 deletions
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
-8
View File
@@ -50,14 +50,6 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Test Effect simplification rules
if: runner.os == 'Linux'
run: bun run test:effect-simplification-rules
- name: Check Effect simplifications
if: runner.os == 'Linux'
run: bun run lint:effect-simplifications
- name: Configure git identity
run: |
git config --global user.email "bot@opencode.ai"
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -1,3 +1,19 @@
{
"$schema": "https://opencode.ai/tui.json"
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"smoke_modal": "ctrl+alt+m",
"smoke_screen": "ctrl+alt+o",
"smoke_screen_home": "escape,ctrl+shift+h",
"smoke_screen_modal": "ctrl+alt+m",
"smoke_dialog_close": "escape,q"
}
}
]
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
+1942 -684
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-PatsUdaitHvSUpS5gkC5J2rsUNB5vwJKqHdlOFaKk70=",
"aarch64-linux": "sha256-gTRQMAADH/SpQ8yh+YS2IcnmQxnJcU4FUah2YfrKeP8=",
"aarch64-darwin": "sha256-QTqlwmugYh+iu5Sh/Hxv01NXH/OhzcQ8ObVUbA9A8AM=",
"x86_64-darwin": "sha256-0DPAbNCVw2nUMWkIGEhB6saMdxRRwAJi7wAoWCQc7xQ="
"x86_64-linux": "sha256-8pRvkbUX2aZhFTFtFuUM6mPqZZhfC4mFd1+BXVMzEJk=",
"aarch64-linux": "sha256-df25TWdjjLKeLZJEfrDpgVaV8ZAZhHWPoV2IPIQ4U2w=",
"aarch64-darwin": "sha256-VjbOx7Zi9eTiPxqpKN3+EQWweBfJHf7y36sGSN1peg0=",
"x86_64-darwin": "sha256-q7nW4AR2OnepnDcPDtYECgcsXI+JRHOCWhPsAX8t7q0="
}
}
+4 -5
View File
@@ -18,16 +18,13 @@
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -36,7 +33,8 @@
"packages": [
"packages/*",
"packages/console/*",
"packages/stats/*"
"packages/stats/*",
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.110",
@@ -129,6 +127,7 @@
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
@@ -175,6 +174,6 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
}
}
+1 -1
View File
@@ -370,7 +370,7 @@ const responseError = Effect.fn("RecordingEnv.responseError")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
if (response.status >= 200 && response.status < 300) return undefined
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
})
+14 -43
View File
@@ -37,10 +37,6 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
}
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
// so their tool-result attachments lower as a separate user turn instead.
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
@@ -217,7 +213,6 @@ interface ParserState {
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
readonly textSignature?: string
}
// =============================================================================
@@ -289,16 +284,8 @@ const lowerToolCall = (part: ToolCallPart) => ({
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
let pendingMedia: GeminiInlineDataPart[] | undefined
const flushMedia = () => {
if (!pendingMedia) return
contents.push({ role: "user", parts: [{ text: "Attached media from tool result:" }, ...pendingMedia] })
pendingMedia = undefined
}
for (const message of request.messages) {
if (message.role !== "tool") flushMedia()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1)
@@ -329,7 +316,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") {
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
parts.push({ text: part.text })
continue
}
if (part.type === "reasoning") {
@@ -380,7 +367,6 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
const value = ProviderShared.normalizeToolFile(item)
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
}
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
@@ -389,7 +375,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
name: part.name,
content: text.join("\n"),
},
parts: legacyToolMedia || media.length === 0 ? undefined : media,
parts: media.length > 0 ? media : undefined,
},
})
}
@@ -401,7 +387,6 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
else contents.push({ role: "user", parts })
}
flushMedia()
return contents
})
@@ -541,16 +526,14 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (finishReason === undefined && state.usage === undefined) return []
const events: LLMEvent[] = []
let lifecycle = state.lifecycle
if (state.reasoningSignature !== undefined)
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
const lifecycle = state.reasoningSignature
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
: state.lifecycle
Lifecycle.finish(lifecycle, events, {
reason: {
normalized:
@@ -582,14 +565,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
for (const part of candidate.content.parts) {
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature
else if (signature !== undefined && "text" in part) textSignature = signature
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
reasoningSignature = part.thoughtSignature
if ("text" in part && part.text.length > 0) {
if (part.thought) {
lifecycle = Lifecycle.reasoningDelta(
@@ -597,7 +576,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
"reasoning-0",
part.text,
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
)
continue
}
@@ -607,14 +586,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.textDelta(
lifecycle,
events,
"text-0",
part.text,
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
)
textSignature = undefined
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
continue
}
@@ -651,7 +623,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
nextToolCallId,
reasoningSignature,
textSignature,
finishReason: candidate.finishReason ?? nextState.finishReason,
},
events,
+2 -8
View File
@@ -21,15 +21,9 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (
state: State,
events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
): State => {
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
events.push(LLMEvent.textDelta({ id, text }))
return started
}
+5 -214
View File
@@ -328,18 +328,14 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
},
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
])
expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
}),
@@ -372,161 +368,11 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "" },
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
},
},
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
],
},
])
}),
)
it.effect("nests media inside function responses for gemini 3", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([
ToolCallPart.make({
id: "call_image",
name: "read",
input: { path: "pixel.png" },
providerMetadata: { google: { thoughtSignature: "sig_1" } },
}),
]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
},
{
role: "user",
parts: [
{
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
},
},
],
},
])
}),
)
it.effect("flushes pending media before system update text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "shot", input: {} })]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.system("Update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { name: "shot", args: {} } }] },
{
role: "user",
parts: [{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } }],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
{ text: "<system-update>\nUpdate.\n</system-update>" },
],
},
])
}),
)
it.effect("collects legacy tool media into one turn after merged responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "shot", input: {} }),
ToolCallPart.make({ id: "call_2", name: "shot", input: {} }),
]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.tool({
id: "call_2",
name: "shot",
result: {
type: "content",
value: [{ type: "text", text: "no image here" }],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { name: "shot", args: {} } },
{ functionCall: { name: "shot", args: {} } },
],
},
{
role: "user",
parts: [
{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } },
{ functionResponse: { name: "shot", response: { name: "shot", content: "no image here" } } },
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
],
},
])
}),
)
@@ -905,61 +751,6 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves thoughtSignature on visible text parts", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
expect(delta).toMatchObject({
id: "text-0",
text: "All done.",
providerMetadata: { google: { thoughtSignature: "text_sig" } },
})
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
])
}),
)
it.effect("flushes a trailing empty signed text part at block close", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ text: "Working." }, { text: "", thoughtSignature: "tail_sig" }],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
const end = response.events.find((event) => event.type === "text-end")
expect(delta).toMatchObject({ id: "text-0", text: "Working.", providerMetadata: undefined })
expect(end).toMatchObject({
id: "text-0",
providerMetadata: { google: { thoughtSignature: "tail_sig" } },
})
}),
)
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -7,7 +7,6 @@ import type { ComposerModel } from "./model"
import { createComposerEditor } from "./editor/interaction"
import type { ComposerPersistedState, ComposerSuggestion } from "./types"
import { buildPromptRequest } from "./request"
import { promptLength } from "./prompt-parts"
import { SessionPreview } from "@/session/story-model"
import { Skill } from "@opencode-ai/schema/skill"
import { resolveSessionComposerSelection } from "@/session/composer/selection"
@@ -58,7 +57,7 @@ function ComposerStory(props: {
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
cursor: props.prompt ? promptLength(props.prompt) : 0,
cursor: props.prompt?.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) ?? 0,
model: { providerID: STORY_MODEL.providerID, modelID: STORY_MODEL.id, variant: STORY_MODEL.variant },
context: { items: props.comments ?? [] },
})
+4 -1
View File
@@ -7,7 +7,6 @@ import type {
ComposerPersistedState,
ComposerPrompt,
} from "../types"
import { promptLength } from "../prompt-parts"
export type ComposerStateStore = [
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
@@ -135,3 +134,7 @@ function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
return next
})
}
function promptLength(prompt: ComposerPrompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
@@ -18,7 +18,6 @@ import {
type ComposerInteractionCommand,
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, promptLength } from "../prompt-parts"
export type ComposerSelectControl = {
options: Accessor<ComposerOption[]>
@@ -435,6 +434,16 @@ function canNavigateHistory(direction: "up" | "down", text: string, cursor: numb
return position === text.length
}
function clonePrompt(prompt: ComposerPersistedState["prompt"]): ComposerPersistedState["prompt"] {
return prompt.map((part) =>
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
)
}
function promptLength(prompt: ComposerPersistedState["prompt"]) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
function editorCursor(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
+23 -24
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { clonePromptParts, prependHistoryEntry, promptLength, type PromptHistoryComment } from "./entry"
import { upgradeHistoryState } from "./store"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -34,32 +34,31 @@ describe("Composer history", () => {
expect(dedupedComments).toBe(commentsOnly)
})
test("insertion isolates canonical entries from source mutations", () => {
const prompt: Prompt = [
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 0,
end: 9,
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 0 },
},
]
const comments = [comment("c1")]
const entries = prependHistoryEntry([], prompt, comments)
const stored = entries[0]
if (prompt[0]?.type !== "file" || stored?.prompt[0]?.type !== "file") throw new Error("expected file")
prompt[0].selection!.startLine = 9
comments[0].selection.start = 9
expect(stored.prompt[0].selection?.startLine).toBe(1)
expect(stored.comments[0]?.selection.start).toBe(2)
})
test("upgrades stored prompt arrays once at the persistence boundary", () => {
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
entries: [{ prompt: text("stored"), comments: [] }],
})
})
test("helpers clone prompt and count text content length", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 3,
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)
expect(promptLength(copy)).toBe(12)
if (copy[1]?.type !== "file") throw new Error("expected file")
copy[1].selection!.startLine = 9
if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1)
})
})
+28 -3
View File
@@ -1,6 +1,5 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt } from "../prompt-parts"
export const MAX_HISTORY = 100
@@ -21,6 +20,19 @@ export type PromptHistoryEntry = {
export type PromptHistoryStoredEntry = PromptHistoryEntry
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "skill") return { ...part }
return {
...part,
selection: part.selection ? { ...part.selection } : undefined,
}
})
}
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
return {
start: selection.start,
@@ -37,6 +49,17 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
}))
}
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
return {
prompt: clonePromptParts(entry.prompt),
comments: clonePromptHistoryComments(entry.comments),
}
}
export function promptLength(prompt: Prompt) {
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
}
export function prependHistoryEntry(
entries: PromptHistoryStoredEntry[],
prompt: Prompt,
@@ -52,7 +75,7 @@ export function prependHistoryEntry(
if (!text && !hasImages && !hasComments) return entries
const entry = {
prompt: clonePrompt(prompt),
prompt: clonePromptParts(prompt),
comments: clonePromptHistoryComments(comments),
} satisfies PromptHistoryEntry
const last = entries[0]
@@ -73,7 +96,9 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC
)
}
function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistoryStoredEntry) {
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
const entryA = normalizePromptHistoryEntry(promptA)
const entryB = normalizePromptHistoryEntry(promptB)
if (entryA.prompt.length !== entryB.prompt.length) return false
for (let i = 0; i < entryA.prompt.length; i++) {
const partA = entryA.prompt[i]
+3 -3
View File
@@ -3,11 +3,11 @@ import type { Prompt } from "@/composer/state"
import { Persist, persisted } from "@/runtime/persistence/storage"
import {
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./entry"
import { clonePrompt } from "../prompt-parts"
export type ComposerHistoryStore = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@@ -23,7 +23,7 @@ export function upgradeHistoryState(value: unknown) {
return {
...value,
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
if (Array.isArray(entry)) return [{ prompt: clonePrompt(entry as Prompt), comments: [] }]
if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }]
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
return [entry as PromptHistoryStoredEntry]
@@ -64,7 +64,7 @@ export function createComposerHistory() {
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
const saved = clonePrompt(prompt)
const saved = clonePromptParts(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
+6 -2
View File
@@ -18,7 +18,7 @@ import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerAdapter, ComposerControls } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { normalizePromptHistoryEntry, type PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
@@ -284,7 +284,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
store: prompt.store,
state: interaction,
history: {
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
entries: (mode) =>
history.entries(mode).map((value) => {
const entry = normalizePromptHistoryEntry(value)
return { prompt: entry.prompt, metadata: entry.comments }
}),
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
capture: historyComments,
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
@@ -1,43 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
describe("composer prompt parts", () => {
test("clones parts shallowly and copies file selections", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 3,
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePrompt(original)
expect(copy).not.toBe(original)
expect(copy[0]).not.toBe(original[0])
expect(copy[1]).not.toBe(original[1])
expect(copy[2]).not.toBe(original[2])
if (copy[1]?.type !== "file" || original[1]?.type !== "file") throw new Error("expected file parts")
if (copy[2]?.type !== "image" || original[2]?.type !== "image") throw new Error("expected image parts")
expect(copy[2].blob).toBe(original[2].blob)
expect(copy[1].selection).not.toBe(original[1].selection)
copy[1].selection!.startLine = 9
expect(original[1].selection?.startLine).toBe(1)
})
test("counts the content of text and mention parts", () => {
const prompt: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{ type: "agent", content: "@build", start: 3, end: 9, name: "build" },
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
expect(promptLength(prompt)).toBe(9)
})
})
-11
View File
@@ -1,11 +0,0 @@
import type { Prompt } from "./state"
export function clonePrompt(prompt: Prompt): Prompt {
return prompt.map((part) =>
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
)
}
export function promptLength(prompt: Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
+20 -1
View File
@@ -8,7 +8,6 @@ import type { BlobReference } from "@/runtime/persistence/drafts"
import type { Platform } from "@/runtime/platform/platform"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
import { clonePrompt } from "./prompt-parts"
interface PartBase {
content: string
@@ -109,6 +108,26 @@ type InitialPrompt = {
model?: PromptModel
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "skill") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
+6 -3
View File
@@ -1,9 +1,8 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
import { clonePromptParts, type PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
@@ -49,7 +48,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const submission = createComposerSubmission({
target: input.adapter.state,
prompt: clonePrompt(input.adapter.state.current()),
prompt: clonePromptParts(input.adapter.state.current()),
context: input.adapter.state.context.items().map((item) => ({
...item,
selection: item.selection ? { ...item.selection } : undefined,
@@ -318,3 +317,7 @@ function failSubmission(
restore()
input.notify.failed(kind, error)
}
function promptLength(prompt: Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
@@ -236,7 +236,6 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.window": "Window",
"desktop.menu.help": "Help",
"desktop.menu.checkForUpdates": "Check for Updates...",
"desktop.menu.installCli": "Install CLI...",
"desktop.menu.settings": "Settings",
"desktop.menu.reloadWebview": "Reload Webview",
"desktop.menu.restart": "Restart",
@@ -284,11 +283,6 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.updater.dialog.restart": "Restart",
"desktop.updater.dialog.later": "Later",
"desktop.cli.installed.title": "CLI Installed",
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode2' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
"desktop.recovery.action.relaunch": "Relaunch",
"desktop.recovery.action.exportLogs": "Export Logs",
"desktop.recovery.action.keepWaiting": "Keep Waiting",
@@ -53,6 +53,22 @@ function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export const loadGlobalConfigQuery = (scope: ServerScope) =>
queryOptions({
queryKey: [scope, "config"],
@@ -110,6 +126,9 @@ export async function bootstrapGlobal(input: {
readonly worktree: WorktreeApi
}
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) {
@@ -122,6 +141,12 @@ export async function bootstrapGlobal(input: {
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
// showErrors({
// errors: errors(),
// title: input.requestFailedTitle,
// translate: input.translate,
// formatMoreCount: input.formatMoreCount,
// })
}
function projectID(directory: string, projects: Project[]) {
+3
View File
@@ -97,6 +97,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
await bootstrapGlobal({
serverAPI: serverSDK.api,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
queryClient,
})
+1 -1
View File
@@ -10,7 +10,7 @@ import { createComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
import { setCursorPosition } from "@/composer/editor/dom"
import { promptLength } from "@/composer/prompt-parts"
import { promptLength } from "@/composer/history/entry"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
@@ -16,9 +16,12 @@ import {
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
createSoundSettingsController,
soundOptions,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
type SoundSettingsController,
} from "./controllers"
import "@/settings/settings.css"
import { ServerConnection } from "@/runtime/server/registry"
@@ -47,6 +50,24 @@ const fontSettings = {
input: "setTerminal",
},
} as const
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const language = useLanguage()
return (
@@ -207,6 +228,43 @@ const FontSetting: Component<{
)
}
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsList>
<SoundSetting kind="agent" channel={props.controller.agent} />
<SoundSetting kind="permissions" channel={props.controller.permissions} />
<SoundSetting kind="errors" channel={props.controller.errors} />
</SettingsList>
</div>
)
}
const SoundSetting: Component<{
kind: "agent" | "permissions" | "errors"
channel: SoundSettingsController["agent"]
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRow title={language.t(config().title)} description={language.t(config().description)}>
<Select
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRow>
)
}
const LanguageSetting = () => {
const language = useLanguage()
const options = createMemo(() =>
@@ -2,13 +2,6 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("installs the CLI from the macOS application menu", () => {
const appMenu = DESKTOP_MENU.find((menu) => menu.id === "app")
const item = appMenu?.items?.find((entry) => entry.type === "item" && entry.action === "app.installCli")
expect(item).toEqual({ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" })
})
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
@@ -4,7 +4,6 @@ export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction =
| "app.checkForUpdates"
| "app.installCli"
| "app.relaunch"
| "edit.undo"
| "edit.redo"
@@ -85,7 +84,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
action: "app.checkForUpdates",
enabled: "updater",
},
{ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" },
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
+45 -40
View File
@@ -1,6 +1,6 @@
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { createMemo, createResource, For, Index, type JSXElement, Show } from "solid-js"
import { createMemo, createResource, For, type JSXElement, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -27,12 +27,14 @@ export function StatusPopoverBody(props: { shown: boolean }) {
const language = useLanguage()
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcpServers = createMemo(() =>
(data.location.mcp.server.list({ directory: sdk().directory }) ?? []).toSorted((a, b) =>
a.name.localeCompare(b.name),
),
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
const mcpNames = createMemo(() =>
mcp()
.map((server) => server.name)
.sort((a, b) => a.localeCompare(b)),
)
const mcpConnected = createMemo(() => mcpServers().filter((server) => server.status.status === "connected").length)
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
@@ -56,25 +58,26 @@ export function StatusPopoverBody(props: { shown: boolean }) {
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
<Show when={true}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Show>
</Tabs.List>
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpServers().length > 0}
when={mcpNames().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
}
>
<Index each={mcpServers()}>
{(server) => {
const name = () => server().name
const status = () => server().status.status
<For each={mcpNames()}>
{(name) => {
const status = () => mcpStatus(name)
const enabled = () => status() === "connected"
return (
<button
@@ -82,9 +85,9 @@ export function StatusPopoverBody(props: { shown: boolean }) {
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name())
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
>
<div
classList={{
@@ -97,7 +100,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
/>
<span class="flex flex-col min-w-0 flex-1">
<span class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-base truncate">{name()}</span>
<span class="text-14-regular text-text-base truncate">{name}</span>
</span>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker truncate">
@@ -109,41 +112,43 @@ export function StatusPopoverBody(props: { shown: boolean }) {
<Switch
appearance="standard"
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name())
toggleMcp.mutate(name)
}}
/>
</div>
</button>
)
}}
</Index>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Show when={true}>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
</Show>
</Tabs>
</div>
)
+5 -5
View File
@@ -58,11 +58,11 @@
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@ff-labs/fff-bin-darwin-arm64": "0.10.5",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.5",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.5",
"@ff-labs/fff-bin-win32-arm64": "0.10.5",
"@ff-labs/fff-bin-win32-x64": "0.10.5",
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
"@ff-labs/fff-bin-win32-x64": "0.10.1",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, loadIntegrations } from "./shared"
import { errorMessage } from "../../../util/error"
import { errorMessage } from "../../../ui/prompt"
export default Runtime.handler(Commands.commands.auth.commands.list, (input) =>
list(input).pipe(
@@ -6,7 +6,6 @@ import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
import { errorMessage } from "../../../util/error"
const integrationID = "opencode"
const location = { directory: process.cwd() }
@@ -25,9 +24,9 @@ export default Runtime.handler(
if (Exit.isSuccess(exit)) return
const cancelled = timeline.signal.aborted
yield* request(() =>
timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(Cause.squash(exit.cause))),
).pipe(Effect.ignore)
yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe(
Effect.ignore,
)
process.exitCode = cancelled ? 130 : 1
}),
)
@@ -108,3 +107,12 @@ function request<A>(task: (signal: AbortSignal) => Promise<A>) {
function required<A>(value: A | null | undefined, message: string) {
return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
}
function errorMessage(cause: Cause.Cause<unknown>) {
const error = Cause.squash(cause)
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message
}
return String(error)
}
@@ -9,7 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
@@ -9,7 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.debug.commands.config,
Effect.fn("cli.debug.config")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
process.stdout.write(JSON.stringify(entries, null, 2) + EOL)
+1 -1
View File
@@ -6,7 +6,7 @@ import { EOL } from "node:os"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { errorMessage } from "../../util/error"
import { errorMessage } from "../../ui/prompt"
export default Runtime.handler(
Commands.commands.export,
@@ -17,7 +17,9 @@ const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.mcp.commands.auth,
Effect.fn("cli.mcp.auth")(function* (input) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
@@ -9,7 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.mcp.commands.list,
Effect.fn("cli.mcp.list")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
@@ -12,7 +12,9 @@ const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.mcp.commands.logout,
Effect.fn("cli.mcp.logout")(function* (input) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
@@ -12,7 +12,9 @@ import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugi
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* (input) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const config = yield* Config.Service
@@ -4,11 +4,13 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
const transport = yield* Service.ensure(options)
process.stdout.write(transport.url + EOL)
@@ -3,10 +3,13 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () {
yield* Service.stop(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
}),
)
+2 -2
View File
@@ -31,7 +31,7 @@ export const layer = Layer.effect(
const file = path.join(global.config, "cli.json")
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
@@ -87,7 +87,7 @@ export const layer = Layer.effect(
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "{}"))
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
+1 -1
View File
@@ -258,7 +258,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
const readJson = Effect.fnUntraced(function* (target: string) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(target).pipe(Effect.orElseSucceed(() => undefined))
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
+7 -1
View File
@@ -10,7 +10,6 @@ import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive"
import { UI } from "./ui"
import { Env } from "../env"
import { errorMessage } from "../util/error"
export type RunCommandInput = {
server: ServerConnection.Resolved
@@ -244,6 +243,13 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
return error.message
return String(error)
}
/** @internal Used by the V1 command boundary before a Session exists. */
export function reportRunError(input: Pick<RunCommandInput, "format">, message: string, sessionID?: string) {
process.exitCode = 1
@@ -56,12 +56,22 @@ function managedService(options: EnsureOptions) {
reconnect: () => Service.ensure(reconnectOptions),
restart: () =>
Effect.gen(function* () {
yield* shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
yield* Service.ensure(reconnectOptions)
}),
}
}
export const shutdownPersistentPty = Effect.fn("cli.server-connection.shutdown-persistent-pty")(function* (
options: EnsureOptions,
) {
const endpoint = yield* Service.discover({ ...options, version: undefined })
if (!endpoint) return
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* Effect.tryPromise(() => client["server.persistentPty"].shutdown())
})
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
+1 -1
View File
@@ -119,7 +119,7 @@ export const read = Effect.fn("cli.service-config.read")(function* () {
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.orElseSucceed(() => ({}) as Info),
Effect.catch(() => Effect.succeed({} as Info)),
)
})
+2 -2
View File
@@ -44,7 +44,7 @@ export const layer = Layer.effect(
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs.readFileString(path.join(global.config, name)).pipe(
Effect.map(decodePolicy),
Effect.orElseSucceed(() => undefined),
Effect.catch(() => Effect.succeed(undefined)),
),
)
return values.findLast((value) => value !== undefined) ?? true
@@ -63,7 +63,7 @@ export const layer = Layer.effect(
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
)
})
+8 -1
View File
@@ -1,6 +1,5 @@
import { cancel, isCancel, log, outro } from "@clack/prompts"
import { Effect } from "effect"
import { errorMessage } from "../util/error"
const cancelled = Symbol("cancelled")
@@ -39,3 +38,11 @@ export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
),
)
}
export function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message
}
return String(error)
}
-7
View File
@@ -1,7 +0,0 @@
export function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message
}
return String(error)
}
-3
View File
@@ -33,14 +33,12 @@ describe("debug config command", () => {
},
]
let requested: URL | undefined
let healthProbes = 0
const authorization: Array<string | null> = []
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") {
healthProbes += 1
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}
requested = url
@@ -62,7 +60,6 @@ describe("debug config command", () => {
expect(requested?.pathname).toBe("/api/config")
expect(requested?.searchParams.get("location[directory]")).toBe(project)
expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`])
expect(healthProbes).toBe(1)
} finally {
server.stop(true)
await fs.rm(root, { recursive: true, force: true })
@@ -35,9 +35,7 @@ describe("CLI frontend import boundaries", () => {
test("keeps run and Mini on separate evaluation graphs", async () => {
const run = await bundleInputs("packages/cli/src/commands/handlers/run.ts")
expect(run).toContain("packages/cli/src/run/run.ts")
expect(run).toContain("packages/cli/src/util/error.ts")
expect(run).toContain("packages/tui/src/mini/tool.ts")
expect(run).not.toContain("packages/cli/src/ui/prompt.ts")
expect(run).not.toContain("packages/tui/src/mini/runtime.ts")
expect(run).not.toContain("packages/tui/src/mini/runtime.lifecycle.ts")
expect(run).not.toContain("packages/tui/src/mini/footer.ts")
+265 -59
View File
@@ -1449,44 +1449,249 @@ export interface PtyApi<E = never> {
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
}
export type Endpoint21_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
export type Endpoint21_0Output = ReadonlyArray<{
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}>
export type ServerPersistentPtyGroupListOperation<E = never> = () => Effect.Effect<Endpoint21_0Output, E>
export type Endpoint21_1Input = {
readonly items?:
| ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
| undefined
}
export type Endpoint21_1Output = {
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupCreateOperation<E = never> = (
input?: Endpoint21_1Input,
) => Effect.Effect<Endpoint21_1Output, E>
export type Endpoint21_2Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type Endpoint21_2Output = {
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupGetOperation<E = never> = (
input: Endpoint21_2Input,
) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint21_3Input = {
readonly groupID: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type Endpoint21_3Output = {
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupSetOperation<E = never> = (
input: Endpoint21_3Input,
) => Effect.Effect<Endpoint21_3Output, E>
export type Endpoint21_4Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type Endpoint21_4Output = void
export type ServerPersistentPtyGroupRemoveOperation<E = never> = (
input: Endpoint21_4Input,
) => Effect.Effect<Endpoint21_4Output, E>
export type Endpoint21_5Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type Endpoint21_5Output = ReadonlyArray<{
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}>
export type ServerPersistentPtyListOperation<E = never> = (
input: Endpoint21_5Input,
) => Effect.Effect<Endpoint21_5Output, E>
export type Endpoint21_6Input = {
readonly groupID: string & Brand.Brand<"GroupID">
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number } | undefined
}
export type Endpoint21_6Output = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyCreateOperation<E = never> = (
input: Endpoint21_6Input,
) => Effect.Effect<Endpoint21_6Output, E>
export type Endpoint21_7Output = void
export type ServerPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<Endpoint21_7Output, E>
export type Endpoint21_8Input = { readonly ptyID: Pty.ID }
export type Endpoint21_8Output = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyGetOperation<E = never> = (
input: Endpoint21_8Input,
) => Effect.Effect<Endpoint21_8Output, E>
export type Endpoint21_9Input = {
readonly ptyID: Pty.ID
readonly attachmentID?: string | undefined
readonly size: { readonly cols: number; readonly rows: number }
}
export type Endpoint21_9Output = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyUpdateOperation<E = never> = (
input: Endpoint21_9Input,
) => Effect.Effect<Endpoint21_9Output, E>
export type Endpoint21_10Input = { readonly ptyID: Pty.ID }
export type Endpoint21_10Output = {
readonly info: {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
readonly text: string
readonly checkpoint: globalThis.Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type ServerPersistentPtySnapshotOperation<E = never> = (
input: Endpoint21_10Input,
) => Effect.Effect<Endpoint21_10Output, E>
export type Endpoint21_11Input = { readonly ptyID: Pty.ID }
export type Endpoint21_11Output = void
export type ServerPersistentPtyRemoveOperation<E = never> = (
input: Endpoint21_11Input,
) => Effect.Effect<Endpoint21_11Output, E>
export type Endpoint21_12Input = { readonly ptyID: Pty.ID }
export type Endpoint21_12Output = PtyTicket.ConnectToken
export type ServerPersistentPtyConnectTokenOperation<E = never> = (
input: Endpoint21_12Input,
) => Effect.Effect<Endpoint21_12Output, E>
export type Endpoint21_13Input = { readonly ptyID: Pty.ID }
export type Endpoint21_13Output = boolean
export type ServerPersistentPtyConnectOperation<E = never> = (
input: Endpoint21_13Input,
) => Effect.Effect<Endpoint21_13Output, E>
export interface ServerPersistentPtyApi<E = never> {
readonly group: {
readonly list: ServerPersistentPtyGroupListOperation<E>
readonly create: ServerPersistentPtyGroupCreateOperation<E>
readonly get: ServerPersistentPtyGroupGetOperation<E>
readonly set: ServerPersistentPtyGroupSetOperation<E>
readonly remove: ServerPersistentPtyGroupRemoveOperation<E>
}
readonly list: ServerPersistentPtyListOperation<E>
readonly create: ServerPersistentPtyCreateOperation<E>
readonly shutdown: ServerPersistentPtyShutdownOperation<E>
readonly get: ServerPersistentPtyGetOperation<E>
readonly update: ServerPersistentPtyUpdateOperation<E>
readonly snapshot: ServerPersistentPtySnapshotOperation<E>
readonly remove: ServerPersistentPtyRemoveOperation<E>
readonly connectToken: ServerPersistentPtyConnectTokenOperation<E>
readonly connect: ServerPersistentPtyConnectOperation<E>
}
export type Endpoint22_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
export type ShellListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
export type Endpoint22_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly command: string
readonly cwd?: string | undefined
readonly timeout: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
}
export type Endpoint21_1Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effect.Effect<Endpoint21_1Output, E>
export type Endpoint22_1Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellCreateOperation<E = never> = (input: Endpoint22_1Input) => Effect.Effect<Endpoint22_1Output, E>
export type Endpoint21_2Input = {
export type Endpoint22_2Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint22_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: Endpoint22_2Input) => Effect.Effect<Endpoint22_2Output, E>
export type Endpoint21_3Input = {
export type Endpoint22_3Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly timeout: number
}
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Effect.Effect<Endpoint21_3Output, E>
export type Endpoint22_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellTimeoutOperation<E = never> = (input: Endpoint22_3Input) => Effect.Effect<Endpoint22_3Output, E>
export type Endpoint21_4Input = {
export type Endpoint22_4Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}
export type Endpoint21_4Output = {
export type Endpoint22_4Output = {
readonly location: Location.Info
readonly data: {
readonly output: string
@@ -1495,14 +1700,14 @@ export type Endpoint21_4Output = {
readonly truncated: boolean
}
}
export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effect.Effect<Endpoint21_4Output, E>
export type ShellOutputOperation<E = never> = (input: Endpoint22_4Input) => Effect.Effect<Endpoint22_4Output, E>
export type Endpoint21_5Input = {
export type Endpoint22_5Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
export type Endpoint22_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint22_5Input) => Effect.Effect<Endpoint22_5Output, E>
export interface ShellApi<E = never> {
readonly list: ShellListOperation<E>
@@ -1513,41 +1718,41 @@ export interface ShellApi<E = never> {
readonly remove: ShellRemoveOperation<E>
}
export type Endpoint22_0Input = {
export type Endpoint23_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type Endpoint23_0Input = { readonly projectID: Project.ID }
export type Endpoint23_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
export type Endpoint24_0Input = { readonly projectID: Project.ID }
export type Endpoint24_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint23_1Input = {
export type Endpoint24_1Input = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly directory: AbsolutePath
readonly name?: string | undefined
}
export type Endpoint23_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
export type Endpoint24_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint23_2Input = {
export type Endpoint24_2Input = {
readonly projectID: Project.ID
readonly directory: AbsolutePath
readonly force: boolean
}
export type Endpoint23_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E>
export type Endpoint24_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type Endpoint23_3Input = { readonly projectID: Project.ID }
export type Endpoint23_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E>
export type Endpoint24_3Input = { readonly projectID: Project.ID }
export type Endpoint24_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
export interface WorktreeApi<E = never> {
readonly list: WorktreeListOperation<E>
@@ -1556,25 +1761,25 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type Endpoint24_0Input = {
export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint24_1Input = {
export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint24_2Input = {
export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly context?: number | undefined
}
export type Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
@@ -1582,20 +1787,20 @@ export interface VcsApi<E = never> {
readonly diff: VcsDiffOperation<E>
}
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type Endpoint25_1Input = {
export type Endpoint26_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
}
export type Endpoint26_0Output =
export type Endpoint27_0Output =
| { readonly status: "required" | "completed" }
| {
readonly status: "running"
@@ -1606,36 +1811,36 @@ export type Endpoint26_0Output =
}
}
| { readonly status: "error"; readonly error: string }
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
}
export type Endpoint27_0Input = {
export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint27_1Input = {
export type Endpoint28_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly providerID?: WebSearch.ID | undefined
}
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
export interface WebsearchApi<E = never> {
readonly providers: WebsearchProvidersOperation<E>
readonly query: WebsearchQueryOperation<E>
}
export type Endpoint28_0Input = {
export type Endpoint29_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint28_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
@@ -1663,6 +1868,7 @@ export interface AppApi<E = never> {
readonly skill: SkillApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly "server.persistentPty": ServerPersistentPtyApi<E>
readonly shell: ShellApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
+238 -81
View File
@@ -188,7 +188,6 @@ import type {
Endpoint20_4Output,
Endpoint20_5Input,
Endpoint20_5Output,
Endpoint21_0Input,
Endpoint21_0Output,
Endpoint21_1Input,
Endpoint21_1Output,
@@ -200,32 +199,59 @@ import type {
Endpoint21_4Output,
Endpoint21_5Input,
Endpoint21_5Output,
Endpoint21_6Input,
Endpoint21_6Output,
Endpoint21_7Output,
Endpoint21_8Input,
Endpoint21_8Output,
Endpoint21_9Input,
Endpoint21_9Output,
Endpoint21_10Input,
Endpoint21_10Output,
Endpoint21_11Input,
Endpoint21_11Output,
Endpoint21_12Input,
Endpoint21_12Output,
Endpoint21_13Input,
Endpoint21_13Output,
Endpoint22_0Input,
Endpoint22_0Output,
Endpoint22_1Input,
Endpoint22_1Output,
Endpoint22_2Input,
Endpoint22_2Output,
Endpoint22_3Input,
Endpoint22_3Output,
Endpoint22_4Input,
Endpoint22_4Output,
Endpoint22_5Input,
Endpoint22_5Output,
Endpoint23_0Input,
Endpoint23_0Output,
Endpoint23_1Input,
Endpoint23_1Output,
Endpoint23_2Input,
Endpoint23_2Output,
Endpoint23_3Input,
Endpoint23_3Output,
Endpoint24_0Input,
Endpoint24_0Output,
Endpoint24_1Input,
Endpoint24_1Output,
Endpoint24_2Input,
Endpoint24_2Output,
Endpoint24_3Input,
Endpoint24_3Output,
Endpoint25_0Input,
Endpoint25_0Output,
Endpoint25_1Input,
Endpoint25_1Output,
Endpoint25_2Input,
Endpoint25_2Output,
Endpoint26_0Output,
Endpoint27_0Input,
Endpoint26_1Input,
Endpoint26_1Output,
Endpoint27_0Output,
Endpoint27_1Input,
Endpoint27_1Output,
Endpoint28_0Input,
Endpoint28_0Output,
Endpoint28_1Input,
Endpoint28_1Output,
Endpoint29_0Input,
Endpoint29_0Output,
} from "../api/api.js"
import { ClientError } from "./client-error.js"
@@ -1113,28 +1139,158 @@ const adaptGroup20 = (raw: RawClient["server.pty"]) => ({
connect: { token: Endpoint20_5(raw) },
})
const Endpoint21_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint21_0Input) =>
const Endpoint21_0 = (raw: RawClient["server.persistentPty"]) => () =>
preserveEffect<Endpoint21_0Output>()(
raw["persistentPty.group.list"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_1 = (raw: RawClient["server.persistentPty"]) => (input?: Endpoint21_1Input) =>
preserveEffect<Endpoint21_1Output>()(
raw["persistentPty.group.create"]({ payload: { items: input?.["items"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_2 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_2Input) =>
preserveEffect<Endpoint21_2Output>()(
raw["persistentPty.group.get"]({ params: { groupID: input["groupID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_3 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_3Input) =>
preserveEffect<Endpoint21_3Output>()(
raw["persistentPty.group.set"]({ params: { groupID: input["groupID"] }, payload: { items: input["items"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_4 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_4Input) =>
preserveEffect<Endpoint21_4Output>()(
raw["persistentPty.group.remove"]({ params: { groupID: input["groupID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_5 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_5Input) =>
preserveEffect<Endpoint21_5Output>()(
raw["persistentPty.list"]({ params: { groupID: input["groupID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_6 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_6Input) =>
preserveEffect<Endpoint21_6Output>()(
raw["persistentPty.create"]({
params: { groupID: input["groupID"] },
payload: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_7 = (raw: RawClient["server.persistentPty"]) => () =>
preserveEffect<Endpoint21_7Output>()(raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint21_8 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_8Input) =>
preserveEffect<Endpoint21_8Output>()(
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_9 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_9Input) =>
preserveEffect<Endpoint21_9Output>()(
raw["persistentPty.update"]({
params: { ptyID: input["ptyID"] },
payload: { attachmentID: input["attachmentID"], size: input["size"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_10 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_10Input) =>
preserveEffect<Endpoint21_10Output>()(
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_11 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_11Input) =>
preserveEffect<Endpoint21_11Output>()(
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_12 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_12Input) =>
preserveEffect<Endpoint21_12Output>()(
raw["persistentPty.connectToken"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_13 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_13Input) =>
preserveEffect<Endpoint21_13Output>()(
raw["persistentPty.connect"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup21 = (raw: RawClient["server.persistentPty"]) => ({
group: {
list: Endpoint21_0(raw),
create: Endpoint21_1(raw),
get: Endpoint21_2(raw),
set: Endpoint21_3(raw),
remove: Endpoint21_4(raw),
},
list: Endpoint21_5(raw),
create: Endpoint21_6(raw),
shutdown: Endpoint21_7(raw),
get: Endpoint21_8(raw),
update: Endpoint21_9(raw),
snapshot: Endpoint21_10(raw),
remove: Endpoint21_11(raw),
connectToken: Endpoint21_12(raw),
connect: Endpoint21_13(raw),
})
const Endpoint22_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint22_0Input) =>
preserveEffect<Endpoint22_0Output>()(
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_1 = (raw: RawClient["server.shell"]) => (input: Endpoint21_1Input) =>
preserveEffect<Endpoint21_1Output>()(
const Endpoint22_1 = (raw: RawClient["server.shell"]) => (input: Endpoint22_1Input) =>
preserveEffect<Endpoint22_1Output>()(
raw["shell.create"]({
query: { location: input["location"] },
payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_2 = (raw: RawClient["server.shell"]) => (input: Endpoint21_2Input) =>
preserveEffect<Endpoint21_2Output>()(
const Endpoint22_2 = (raw: RawClient["server.shell"]) => (input: Endpoint22_2Input) =>
preserveEffect<Endpoint22_2Output>()(
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint21_3 = (raw: RawClient["server.shell"]) => (input: Endpoint21_3Input) =>
preserveEffect<Endpoint21_3Output>()(
const Endpoint22_3 = (raw: RawClient["server.shell"]) => (input: Endpoint22_3Input) =>
preserveEffect<Endpoint22_3Output>()(
raw["shell.timeout"]({
params: { id: input["id"] },
query: { location: input["location"] },
@@ -1142,134 +1298,134 @@ const Endpoint21_3 = (raw: RawClient["server.shell"]) => (input: Endpoint21_3Inp
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) =>
preserveEffect<Endpoint21_4Output>()(
const Endpoint22_4 = (raw: RawClient["server.shell"]) => (input: Endpoint22_4Input) =>
preserveEffect<Endpoint22_4Output>()(
raw["shell.output"]({
params: { id: input["id"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_5 = (raw: RawClient["server.shell"]) => (input: Endpoint21_5Input) =>
preserveEffect<Endpoint21_5Output>()(
const Endpoint22_5 = (raw: RawClient["server.shell"]) => (input: Endpoint22_5Input) =>
preserveEffect<Endpoint22_5Output>()(
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
list: Endpoint21_0(raw),
create: Endpoint21_1(raw),
get: Endpoint21_2(raw),
timeout: Endpoint21_3(raw),
output: Endpoint21_4(raw),
remove: Endpoint21_5(raw),
const adaptGroup22 = (raw: RawClient["server.shell"]) => ({
list: Endpoint22_0(raw),
create: Endpoint22_1(raw),
get: Endpoint22_2(raw),
timeout: Endpoint22_3(raw),
output: Endpoint22_4(raw),
remove: Endpoint22_5(raw),
})
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
preserveEffect<Endpoint22_0Output>()(
const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
preserveEffect<Endpoint23_0Output>()(
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
preserveEffect<Endpoint23_0Output>()(
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
preserveEffect<Endpoint23_1Output>()(
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
preserveEffect<Endpoint23_2Output>()(
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["worktree.remove"]({
params: { projectID: input["projectID"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
preserveEffect<Endpoint23_3Output>()(
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
preserveEffect<Endpoint24_3Output>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint23_0(raw),
create: Endpoint23_1(raw),
remove: Endpoint23_2(raw),
refresh: Endpoint23_3(raw),
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint24_0(raw),
create: Endpoint24_1(raw),
remove: Endpoint24_2(raw),
refresh: Endpoint24_3(raw),
})
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
preserveEffect<Endpoint25_0Output>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
preserveEffect<Endpoint25_2Output>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint24_0(raw),
status: Endpoint24_1(raw),
diff: Endpoint24_2(raw),
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint25_0(raw),
status: Endpoint25_1(raw),
diff: Endpoint25_2(raw),
})
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
preserveEffect<Endpoint26_1Output>()(
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
})
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
preserveEffect<Endpoint27_0Output>()(
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
preserveEffect<Endpoint27_1Output>()(
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
preserveEffect<Endpoint28_1Output>()(
raw["websearch.query"]({
query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint27_0(raw),
query: Endpoint27_1(raw),
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint28_0(raw),
query: Endpoint28_1(raw),
})
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
preserveEffect<Endpoint29_0Output>()(
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
@@ -1293,14 +1449,15 @@ const adaptClient = (raw: RawClient) => ({
skill: adaptGroup18(raw["server.skill"]),
event: adaptGroup19(raw["server.event"]),
pty: adaptGroup20(raw["server.pty"]),
shell: adaptGroup21(raw["server.shell"]),
reference: adaptGroup22(raw["server.reference"]),
worktree: adaptGroup23(raw["server.worktree"]),
vcs: adaptGroup24(raw["server.vcs"]),
debug: adaptGroup25(raw["server.debug"]),
migration: adaptGroup26(raw["server.migration"]),
websearch: adaptGroup27(raw["server.websearch"]),
config: adaptGroup28(raw["server.config"]),
"server.persistentPty": adaptGroup21(raw["server.persistentPty"]),
shell: adaptGroup22(raw["server.shell"]),
reference: adaptGroup23(raw["server.reference"]),
worktree: adaptGroup24(raw["server.worktree"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
websearch: adaptGroup28(raw["server.websearch"]),
config: adaptGroup29(raw["server.config"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
+8 -4
View File
@@ -28,10 +28,7 @@ export type Info = import("../service.js").Info
// Never spawns; escalation to ensure() is the caller's policy.
/** Discover a healthy, compatible local service without starting one. */
export const discover = Effect.fn("service.discover")(function* (options: DiscoverOptions = {}) {
const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined
if (!matchesVersion(found.version, options)) return undefined
return found.endpoint
return (yield* discoverLocal(options))?.endpoint
})
/** Recognize an authenticated compatible service bound to an expected URL, including while it starts or fails. */
@@ -45,6 +42,13 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
return { endpoint: found.endpoint, state: found.state }
})
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
})
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
// version-mismatched one, and otherwise spawns small contenders until a server
// becomes discoverable. A contender is never killed merely for slow startup.
@@ -184,6 +184,32 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ServerPersistentPtyGroupListOutput,
ServerPersistentPtyGroupCreateInput,
ServerPersistentPtyGroupCreateOutput,
ServerPersistentPtyGroupGetInput,
ServerPersistentPtyGroupGetOutput,
ServerPersistentPtyGroupSetInput,
ServerPersistentPtyGroupSetOutput,
ServerPersistentPtyGroupRemoveInput,
ServerPersistentPtyGroupRemoveOutput,
ServerPersistentPtyListInput,
ServerPersistentPtyListOutput,
ServerPersistentPtyCreateInput,
ServerPersistentPtyCreateOutput,
ServerPersistentPtyShutdownOutput,
ServerPersistentPtyGetInput,
ServerPersistentPtyGetOutput,
ServerPersistentPtyUpdateInput,
ServerPersistentPtyUpdateOutput,
ServerPersistentPtySnapshotInput,
ServerPersistentPtySnapshotOutput,
ServerPersistentPtyRemoveInput,
ServerPersistentPtyRemoveOutput,
ServerPersistentPtyConnectTokenInput,
ServerPersistentPtyConnectTokenOutput,
ServerPersistentPtyConnectInput,
ServerPersistentPtyConnectOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -1588,6 +1614,175 @@ export function make(options: ClientOptions) {
),
},
},
"server.persistentPty": {
group: {
list: (requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupListOutput }>(
{
method: "GET",
path: `/api/pty-group`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input?: ServerPersistentPtyGroupCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupCreateOutput }>(
{
method: "POST",
path: `/api/pty-group`,
body: { items: input?.["items"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: ServerPersistentPtyGroupGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupGetOutput }>(
{
method: "GET",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
set: (input: ServerPersistentPtyGroupSetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupSetOutput }>(
{
method: "PUT",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
body: { items: input["items"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: ServerPersistentPtyGroupRemoveInput, requestOptions?: RequestOptions) =>
request<ServerPersistentPtyGroupRemoveOutput>(
{
method: "DELETE",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
},
list: (input: ServerPersistentPtyListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyListOutput }>(
{
method: "GET",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: ServerPersistentPtyCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyCreateOutput }>(
{
method: "POST",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
body: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
shutdown: (requestOptions?: RequestOptions) =>
request<ServerPersistentPtyShutdownOutput>(
{
method: "POST",
path: `/api/persistent-pty/shutdown`,
successStatus: 204,
declaredStatuses: [503, 401, 400],
empty: true,
},
requestOptions,
),
get: (input: ServerPersistentPtyGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGetOutput }>(
{
method: "GET",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
update: (input: ServerPersistentPtyUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyUpdateOutput }>(
{
method: "PUT",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
body: { attachmentID: input["attachmentID"], size: input["size"] },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
snapshot: (input: ServerPersistentPtySnapshotInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtySnapshotOutput }>(
{
method: "GET",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/snapshot`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: ServerPersistentPtyRemoveInput, requestOptions?: RequestOptions) =>
request<ServerPersistentPtyRemoveOutput>(
{
method: "DELETE",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
connectToken: (input: ServerPersistentPtyConnectTokenInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyConnectTokenOutput }>(
{
method: "POST",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect-token`,
successStatus: 200,
declaredStatuses: [403, 404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
connect: (input: ServerPersistentPtyConnectInput, requestOptions?: RequestOptions) =>
request<ServerPersistentPtyConnectOutput>(
{
method: "GET",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`,
successStatus: 200,
declaredStatuses: [403, 404, 503, 401, 400],
empty: false,
},
requestOptions,
),
},
shell: {
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
request<ShellListOutput>(
+192 -20
View File
@@ -130,21 +130,12 @@ export type SkillInfo = {
export type PermissionReply = "once" | "always" | "reject"
export type Pty = {
id: string
title: string
command: string
args: Array<string>
cwd: string
status: "running" | "exited"
pid: number
exitCode?: number
}
export type FormMetadata1 = { [x: string]: any }
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
export type GroupItem = { type: "session"; id: string } | { type: "terminal"; id: string }
export type SessionStatus =
| { type: "idle" }
| {
@@ -1069,24 +1060,51 @@ export type PermissionReplied = {
data: { sessionID: string; requestID: string; reply: PermissionReply }
}
export type PtyCreated = {
export type Pty = {
id: string
created: number
metadata?: { [x: string]: any }
type: "pty.created"
location?: LocationRef
data: { info: Pty }
title: string
command: string
args: Array<string>
cwd: string
status: "running" | "exited"
pid: number
exitCode?: number
}
export type PtyUpdated = {
export type PersistentPtyInfo = {
id: string
title: string
command: string
args: Array<string>
cwd: string
status: "running" | "exited"
pid: number
exitCode?: number
groupID: string
size: { cols: number; rows: number }
output: { head: number; tail: number }
}
export type GroupItemAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "pty.updated"
type: "group.item.added"
location?: LocationRef
data: { info: Pty }
data: { groupID: string; item: GroupItem }
}
export type GroupItemRemoved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "group.item.removed"
location?: LocationRef
data: { groupID: string; item: GroupItem }
}
export type GroupInfo = { id: string; items: Array<GroupItem> }
export type SessionStatus2 = {
id: string
created: number
@@ -1403,6 +1421,31 @@ export type FormReplied = {
data: { id: string; sessionID: string; answer: FormAnswer }
}
export type PtyCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "pty.created"
location?: LocationRef
data: { info: Pty }
}
export type PtyUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "pty.updated"
location?: LocationRef
data: { info: Pty }
}
export type PersistentPtySnapshot = {
info: PersistentPtyInfo
text: string
checkpoint: string
cursor: { x: number; y: number }
}
export type FormStringField1 = {
key: string
title?: string
@@ -2095,6 +2138,8 @@ export type V2Event =
| FormCreated
| FormReplied
| FormCancelled
| GroupItemAdded
| GroupItemRemoved
| WebsearchUpdated
| SessionStatus2
| SessionIdle
@@ -5491,6 +5536,133 @@ export type PtyConnectTokenOutput = {
data: PtyTicketConnectToken
}
export type ServerPersistentPtyGroupListOutput = { data: Array<GroupInfo> }["data"]
export type ServerPersistentPtyGroupCreateInput = {
readonly items?: {
readonly items?:
| ReadonlyArray<
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
>
| undefined
}["items"]
}
export type ServerPersistentPtyGroupCreateOutput = { data: GroupInfo }["data"]
export type ServerPersistentPtyGroupGetInput = { readonly groupID: { readonly groupID: string }["groupID"] }
export type ServerPersistentPtyGroupGetOutput = { data: GroupInfo }["data"]
export type ServerPersistentPtyGroupSetInput = {
readonly groupID: { readonly groupID: string }["groupID"]
readonly items: {
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
>
}["items"]
}
export type ServerPersistentPtyGroupSetOutput = { data: GroupInfo }["data"]
export type ServerPersistentPtyGroupRemoveInput = { readonly groupID: { readonly groupID: string }["groupID"] }
export type ServerPersistentPtyGroupRemoveOutput = void
export type ServerPersistentPtyListInput = { readonly groupID: { readonly groupID: string }["groupID"] }
export type ServerPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
export type ServerPersistentPtyCreateInput = {
readonly groupID: { readonly groupID: string }["groupID"]
readonly command: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["command"]
readonly args: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["args"]
readonly cwd: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["cwd"]
readonly title: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["title"]
readonly env: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["env"]
readonly size?: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["size"]
}
export type ServerPersistentPtyCreateOutput = { data: PersistentPtyInfo }["data"]
export type ServerPersistentPtyShutdownOutput = void
export type ServerPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
export type ServerPersistentPtyUpdateInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly attachmentID?: {
readonly attachmentID?: string
readonly size: { readonly cols: number; readonly rows: number }
}["attachmentID"]
readonly size: {
readonly attachmentID?: string
readonly size: { readonly cols: number; readonly rows: number }
}["size"]
}
export type ServerPersistentPtyUpdateOutput = { data: PersistentPtyInfo }["data"]
export type ServerPersistentPtySnapshotInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtySnapshotOutput = { data: PersistentPtySnapshot }["data"]
export type ServerPersistentPtyRemoveInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyRemoveOutput = void
export type ServerPersistentPtyConnectTokenInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyConnectTokenOutput = { data: PtyTicketConnectToken }["data"]
export type ServerPersistentPtyConnectInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyConnectOutput = boolean
export type ShellListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+9 -1
View File
@@ -22,10 +22,14 @@ export * from "../service.js"
/** Discover a healthy, compatible local service without starting one. */
export async function discover(options: DiscoverOptions = {}) {
return (await discoverLocal(options))?.endpoint
}
async function discoverLocal(options: DiscoverOptions) {
const found = (await registered(options.file)).service
if (found?.state !== "ready") return undefined
if (!matchesVersion(found.version, options)) return undefined
return found.endpoint
return found
}
/** Ensure a healthy, compatible local service is running. */
@@ -140,6 +144,10 @@ type LocalService = {
readonly legacy: boolean
}
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
return (await probeResult(info, allowLegacy)).service
}
async function probeResult(info: Info, allowLegacy = false, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
+17 -119
View File
@@ -13,7 +13,6 @@ import type {
McpResource,
McpServer,
ModelInfo,
ModelRef,
PermissionSavedInfo,
PermissionRequest,
PermissionReplyInput,
@@ -35,7 +34,6 @@ import type {
WebSearchProvider,
} from "../promise"
import { Worktree } from "@opencode-ai/schema/worktree"
import { SessionID } from "@opencode-ai/schema/session-id"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -228,33 +226,6 @@ export function createData(config: CreateDataInput) {
// rollback — not on POST success, which typically precedes the echo.
const outbox = new Set<string>()
// Session IDs of optimistic create admissions still awaiting acknowledgement
// (the session.created echo or the create response itself). A failed create
// only rolls back a session the server never acknowledged. Unlike
// `creating`, this clears on the echo rather than request settlement.
const sessionOutbox = new Set<string>()
// In-flight optimistic creates by session ID. prompt() gates its POST on
// this so a prompt sent to a still-creating session waits for the session
// to exist server-side instead of failing with "not found".
const creating = new Map<string, Promise<unknown>>()
// Per-session send chain: prompts must be admitted in submission order,
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
// for the previous prompt's POST (settled, so one failure does not block
// the next) before sending its own.
const sending = new Map<string, Promise<unknown>>()
// Register `promise` under `key` until it settles. A later registration
// replaces an earlier one; settlement only clears its own entry.
function track(map: Map<string, Promise<unknown>>, key: string, promise: Promise<unknown>) {
map.set(key, promise)
const settle = () => {
if (map.get(key) === promise) map.delete(key)
}
void promise.then(settle, settle)
}
// Upsert an admitted inbox item into pending, input, and (for user and
// synthetic items) the visible transcript. Used by the inbox.enqueued
// handler and by optimistic prompt admission; the upsert is what reconciles
@@ -414,7 +385,6 @@ export function createData(config: CreateDataInput) {
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
messageIndex.delete(sessionID)
sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.family:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`)
sync.invalidate(`session.permission:${sessionID}`)
@@ -464,7 +434,6 @@ export function createData(config: CreateDataInput) {
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
case "session.created":
sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
// Band-aid: a newly created session starts empty, so live events can be its source of truth.
@@ -1141,117 +1110,46 @@ export function createData(config: CreateDataInput) {
sync.invalidate(`session.pending:${sessionID}`)
},
},
// Optimistic session creation: admit a local record under a
// client-minted ID so a session view can mount immediately, then create
// the session on the server. The session.created echo re-syncs the
// record by ID, so the durable payload replaces the client's guess.
// Returns the ID synchronously along with the in-flight request:
// callers gate session-dependent sends on the request (prompt() gates
// itself on any in-flight create of its session automatically).
create(input: {
id?: string
title?: string
agent?: string
model?: ModelRef
location?: LocationRef
projectID?: string
}) {
const { projectID, ...payload } = input
const id = payload.id ?? SessionID.create()
const location = payload.location ?? defaultLocation()
const fresh = !store.session.info[id]
if (fresh) {
const now = Date.now()
sessionOutbox.add(id)
result.session.remember({
id,
projectID: projectID ?? store.location[locationKey(location)]?.info?.project.id ?? "",
agent: payload.agent,
model: payload.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
title: payload.title,
location,
})
// A mounted optimistic session must not fetch its empty collections
// before creation settles. The session.created echo re-syncs info.
sync.complete(`session.family:${id}`)
sync.complete(`session.pending:${id}`)
sync.complete(`session.message:${id}`)
}
// Wrapped so even a synchronous client failure reaches the rollback.
const request = Promise.resolve()
.then(() => api().session.create({ ...payload, id, location }))
.then((info) => {
sessionOutbox.delete(id)
result.session.remember(info)
return info
})
.catch((error) => {
// Roll back only a record this call admitted and neither the echo
// nor the response has acknowledged: anything else is server state.
if (fresh && sessionOutbox.delete(id)) removeSession(id)
throw error
})
if (fresh) track(creating, id, request)
return { id, request }
},
// Optimistic prompt admission: render the prompt immediately under a
// client-minted ID, send it, and let the durable inbox.enqueued echo
// upsert that same ID with the server's payload. Server admission is
// idempotent per ID, so retrying with the identical payload cannot
// double-admit.
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
const { gate, ...request } = input
const id = request.id ?? SessionMessage.ID.create()
prompt(input: SessionPromptInput) {
const id = input.id ?? SessionMessage.ID.create()
// A retry may reuse an ID that is already rendered — and possibly
// already durable. Admit optimistically only for new IDs so a failed
// retry cannot roll back acknowledged state.
const fresh =
!messageIndex.get(request.sessionID)?.has(id) &&
!store.session.pending[request.sessionID]?.some((item) => item.id === id)
!messageIndex.get(input.sessionID)?.has(id) &&
!store.session.pending[input.sessionID]?.some((item) => item.id === id)
if (fresh) {
outbox.add(id)
admitLocal({
id,
sessionID: request.sessionID,
sessionID: input.sessionID,
timeCreated: Date.now(),
type: "user",
delivery: request.delivery ?? "steer",
delivery: input.delivery ?? "steer",
// Files and skills stay off the optimistic row: their durable
// forms are server-loaded (content, mime, resolution), so they
// fill in when the echo upserts the row.
payload: {
text: request.text,
agents: request.agents?.map((agent) => ({ ...agent })),
metadata: request.metadata,
text: input.text,
agents: input.agents?.map((agent) => ({ ...agent })),
metadata: input.metadata,
},
})
}
// Wrapped so even a synchronous client failure reaches the rollback.
// The POST additionally waits for the caller's gate, for any
// in-flight optimistic create of this session, and for the previous
// prompt's POST: the row renders now, the send happens once the
// session exists server-side and earlier prompts are admitted.
const previous = sending.get(request.sessionID)
const send = Promise.resolve()
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
.then(() => api().session.prompt({ ...request, id }))
track(
sending,
request.sessionID,
send.then(
() => undefined,
() => undefined,
),
)
return send.catch((error) => {
// Roll back only rows this call admitted and the echo has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
throw error
})
return Promise.resolve()
.then(() => api().session.prompt({ ...input, id }))
.catch((error) => {
// Roll back only rows this call admitted and the echo has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(input.sessionID, id)
throw error
})
},
sync(sessionID: string, options?: { children?: boolean }) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
+3 -3
View File
@@ -80,6 +80,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/which": "3.0.4",
"@opencode-ai/shell-scan": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -112,13 +113,12 @@
"@aws-sdk/credential-providers": "3.1057.0",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/shell-scan": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
+10
View File
@@ -20,6 +20,16 @@ const result = await Bun.build({
format: "esm",
packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
plugins: [
{
name: "bundle-shell-scan",
setup(build) {
build.onResolve({ filter: /^@opencode-ai\/shell-scan$/ }, () => ({
path: path.resolve("../shell-scan/src/index.ts"),
}))
},
},
],
splitting: true,
loader: {
".txt": "text",
+3 -2
View File
@@ -127,7 +127,7 @@ function prepareOptions(model: Info, pkg: string) {
options.timeout !== undefined && options.timeout !== null && options.timeout !== false
? AbortSignal.timeout(options.timeout)
: undefined,
].filter((item): item is AbortSignal | AbortController => item !== undefined && item !== null)
].filter((item): item is AbortSignal | AbortController => Boolean(item))
const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController)
const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item))
if (abortSignals.length === 1) opts.signal = abortSignals[0]
@@ -346,7 +346,8 @@ function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, u
const prefix = separator > 0 ? modelID.slice(0, separator) : undefined
if (prefix)
return { ...(gateway === undefined ? {} : { gateway }), [prefix === "amazon" ? "bedrock" : prefix]: model }
if (gateway !== undefined) return { gateway: { ...gateway, ...model } }
if (typeof gateway === "object" && gateway !== null && !Array.isArray(gateway))
return { gateway: { ...gateway, ...model } }
return { gateway: model }
}
+32 -30
View File
@@ -227,7 +227,7 @@ export function configured(options?: Options) {
commit?: (seq: number) => Effect.Effect<void>,
) {
return Effect.gen(function* () {
const durable = definition.durable
const durable = definition?.durable
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string") {
@@ -391,14 +391,14 @@ export function configured(options?: Options) {
commit?: PublishOptions["commit"],
) {
return Effect.gen(function* () {
if (!definition.durable && commit)
if (!definition?.durable && commit)
return yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: "Local commit hooks require a durable event",
}),
)
if (definition.durable) {
if (definition?.durable) {
const aggregateID = (event.data as Record<string, unknown>)[definition.durable.aggregate]
if (typeof aggregateID !== "string")
return yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit).pipe(
@@ -610,35 +610,37 @@ export function configured(options?: Options) {
) {
return Effect.gen(function* () {
const definition = Durable.get(event.type)
if (!definition?.durable)
return yield* Effect.die(
if (!definition?.durable) {
yield* Effect.die(
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
)
yield* durableLocks.withLock(event.aggregateID)(
Effect.gen(function* () {
const payload = {
id: event.id,
created: event.created ?? 0,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}),
)
} else {
yield* durableLocks.withLock(event.aggregateID)(
Effect.gen(function* () {
const payload = {
id: event.id,
created: event.created ?? 0,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}),
)
}
})
}
+1 -1
View File
@@ -134,7 +134,7 @@ const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("Catalog.finalize")(function* () {
finalize: Effect.fn("Catalog.finalize")(function* (catalog) {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
+3 -3
View File
@@ -112,15 +112,15 @@ export const create = (
files: collected,
...(result.ok ? {} : { error: true }),
}
const content: Array<Content> = [
{ type: "text", text: value.output },
const content: Array<Content> = [{ type: "text", text: value.output }]
content.push(
...value.files.map((file) => ({
type: "file" as const,
uri: `data:${file.mime};base64,${file.data}`,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
]
)
const metadata: Metadata = {
toolCalls: value.toolCalls,
...(value.error ? { error: true } : {}),
+50 -41
View File
@@ -17,7 +17,6 @@ import {
Event,
} from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
import { Watcher } from "./filesystem/watcher.js"
@@ -30,8 +29,9 @@ import { ConfigNormalize } from "./config/normalize.js"
import { WellKnown } from "./wellknown.js"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
return entries.findLast((entry): entry is Document => entry.type === "document" && entry.info[key] !== undefined)
?.info[key]
return entries
.filter((entry): entry is Document => entry.type === "document")
.findLast((entry) => entry.info[key] !== undefined)?.info[key]
}
export interface Interface {
@@ -156,37 +156,6 @@ export const layer = (options?: Options) =>
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
})
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).findLast(
(credential) => credential.value.type === "key",
)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
})
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
const entries = yield* wellknown
.entries()
@@ -195,7 +164,38 @@ export const layer = (options?: Options) =>
Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
),
)
return yield* Effect.forEach(entries, loadWellknownEntry).pipe(Effect.map((documents) => documents.flat()))
return yield* Effect.forEach(entries, (entry) =>
Effect.gen(function* () {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).findLast(
(credential) => credential.value.type === "key",
)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
}),
).pipe(Effect.map((documents) => documents.flat()))
})
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
@@ -347,7 +347,7 @@ export const layer = (options?: Options) =>
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
Effect.orElseSucceed(() => false),
Effect.catch(() => Effect.succeed(false)),
),
),
Stream.runForEach(() =>
@@ -449,11 +449,20 @@ type Edit = { readonly path: (string | number)[]; readonly value: unknown }
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (isRecord(before) && isRecord(after)) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
return changes(before[key], after[key], [...path, key])
if (
before !== null &&
after !== null &&
typeof before === "object" &&
typeof after === "object" &&
!Array.isArray(before) &&
!Array.isArray(after)
) {
const previous = before as Record<string, unknown>
const next = after as Record<string, unknown>
return [...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => {
if (!(key in next)) return [{ path: [...path, key], value: undefined }]
if (!(key in previous)) return [{ path: [...path, key], value: next[key] }]
return changes(previous[key], next[key], [...path, key])
})
}
return [{ path, value: after }]
+1 -1
View File
@@ -793,7 +793,7 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
}
function own(value: Record<string, unknown>, key: string) {
return Object.hasOwn(value, key)
return Object.prototype.hasOwnProperty.call(value, key)
}
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
+18 -14
View File
@@ -52,19 +52,22 @@ export const Plugin = define({
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
if (entry.type === "document") return [entry]
if (entry.type !== "directory") return []
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => (content ? decode(file, content) : undefined)),
Effect.orElseSucceed(() => undefined),
),
).pipe(Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)))
})
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
if (entry.type !== "directory") return Effect.succeed([])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => (content ? decode(file, content) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as Document[] }
const reload = load().pipe(
@@ -157,7 +160,8 @@ function isPathAction(action: string): action is PathAction {
}
function expandHome(resource: string, home: string) {
if (resource === "~" || resource === "$HOME") return home
if (resource === "~") return home
if (resource === "$HOME") return home
const relative = resource.startsWith("~/")
? resource.slice(2)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
@@ -176,7 +180,7 @@ function discover(fs: FSUtil.Interface, directory: string) {
),
).pipe(
Effect.map((files) => files.flat()),
Effect.orElseSucceed(() => []),
Effect.catch(() => Effect.succeed([])),
)
}
+11 -9
View File
@@ -17,14 +17,16 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
if (entry.type === "document") return [{ commands: entry.info.commands }]
if (entry.type !== "directory") return []
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
if (entry.type !== "directory") return Effect.succeed([])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as { commands: Info["commands"] }[] }
const reload = load().pipe(
@@ -86,11 +88,11 @@ function loadDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true })
.pipe(Effect.orElseSucceed(() => [] as string[]))
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
return yield* Effect.forEach(files.toSorted(), (filepath) =>
fs.readFileStringSafe(filepath).pipe(
Effect.map((content) => (content === undefined ? undefined : decode(directory, filepath, content))),
Effect.orElseSucceed(() => undefined),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((commands) =>
+15 -4
View File
@@ -1,24 +1,35 @@
export * as ConfigCompactionPlugin from "./compaction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { SessionCompaction } from "../../session/compaction.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.compaction",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const compaction = yield* SessionCompaction.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, compaction.reload())
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(compaction.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* compaction.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.compaction) continue
draft.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
...(entry.info.compaction.keep?.tokens === undefined
? {}
: { tokens: entry.info.compaction.keep.tokens }),
})
}
})
@@ -1,25 +0,0 @@
export * as ConfigEntryObserver from "./entry-observer.js"
import type { EventDomain } from "@opencode-ai/plugin/effect/event"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
export const observe = Effect.fnUntraced(function* (
config: Config.Interface,
event: EventDomain,
reload: Effect.Effect<void>,
) {
const loaded = { entries: yield* config.entries() }
const refresh = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(reload),
)
yield* event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => refresh),
Effect.forkScoped({ startImmediately: true }),
)
// Close the race between the first read and establishing the subscription.
loaded.entries = yield* config.entries()
return loaded
})
+15 -3
View File
@@ -5,12 +5,11 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Formatter } from "../../formatter.js"
import { make, type Info } from "../../formatter/builtins.js"
import { Location } from "../../location.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.formatter",
@@ -22,7 +21,20 @@ export const Plugin = define({
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(formatter.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
// Refetch after subscribing so a config update between the first read and
// the live subscription cannot leave the transform on a stale snapshot.
loaded.entries = yield* config.entries()
yield* formatter.transform((draft) => {
const configured = Config.latest(loaded.entries, "formatter")
+14 -3
View File
@@ -1,17 +1,28 @@
export * as ConfigImagePlugin from "./image.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Image } from "../../image.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.image",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const image = yield* Image.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, image.reload())
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(image.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
// Refetch after subscribing so a config update between the first read and
// the live subscription cannot leave the transform on a stale snapshot.
loaded.entries = yield* config.entries()
yield* image.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document") continue
@@ -1,17 +1,26 @@
export * as ConfigLocationWatcherPlugin from "./location-watcher.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { LocationWatcherPolicy } from "../../filesystem/location-watcher-policy.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.location-watcher",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const policy = yield* LocationWatcherPolicy.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, policy.reload())
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(policy.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* policy.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.watcher?.ignore) continue
+12 -3
View File
@@ -2,16 +2,15 @@ export * as ConfigPolicyPlugin from "./policy.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Wildcard } from "../../util/wildcard.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.policy",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.catalog.reload())
const loaded = { entries: yield* config.entries() }
yield* ctx.catalog.transform((catalog) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
@@ -23,5 +22,15 @@ export const Plugin = define({
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+13 -7
View File
@@ -3,20 +3,15 @@ export * as ConfigProviderPlugin from "./provider.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Provider } from "../../provider.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.provider",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = yield* ConfigEntryObserver.observe(
config,
ctx.event,
ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload())),
)
const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => {
for (const [id, provider] of configuredProviders(loaded.entries)) {
const integrationID = id
@@ -102,6 +97,17 @@ export const Plugin = define({
}
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+12 -3
View File
@@ -4,13 +4,12 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import path from "path"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Reference } from "../../reference.js"
import { AbsolutePath } from "../../schema.js"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.reference",
@@ -18,7 +17,7 @@ export const Plugin = define({
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
const loaded = { entries: yield* config.entries() }
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
@@ -50,6 +49,16 @@ export const Plugin = define({
}
for (const [name, source] of entries) draft.add(name, source)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.reference.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+12 -3
View File
@@ -1,17 +1,26 @@
export * as ConfigShellPlugin from "./shell.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ShellSelect } from "../../shell/select.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.shell",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const shell = yield* ShellSelect.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, shell.reload())
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(shell.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* shell.transform((draft) => {
const configured = Config.latest(loaded.entries, "shell")
if (configured) draft.configure(configured)
+5 -5
View File
@@ -54,7 +54,7 @@ export const Plugin = define({
"ConfigSkillPlugin.watchDirectory",
)(function* (directory: string) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.orElseSucceed(() => undefined))
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) yield* watch(target, "file")
@@ -65,7 +65,7 @@ export const Plugin = define({
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
@@ -124,11 +124,11 @@ export const Plugin = define({
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.orElseSucceed(() => [] as string[]))
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.orElseSucceed(() => filepath))
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.orElseSucceed(() => undefined))
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const parsed = SkillFile.parse(directory, filepath, content)
if (parsed._tag === "Skipped") {
+12 -3
View File
@@ -1,17 +1,26 @@
export * as ConfigSnapshotPlugin from "./snapshot.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Snapshot } from "../../snapshot.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.snapshot",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const snapshot = yield* Snapshot.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, snapshot.reload())
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(snapshot.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* snapshot.transform((draft) => {
const configured = Config.latest(loaded.entries, "snapshots")
if (configured === undefined) return
+1 -1
View File
@@ -148,7 +148,7 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
+12 -3
View File
@@ -1,17 +1,26 @@
export * as ConfigToolOutputPlugin from "./tool-output.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ToolOutput } from "../../tool-output.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.tool-output",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const output = yield* ToolOutput.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, output.reload())
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(output.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* output.transform((draft) => {
const configured = Config.latest(loaded.entries, "tool_output")
if (!configured) return
+12 -3
View File
@@ -1,19 +1,28 @@
export * as ConfigWebSearchPlugin from "./websearch.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.websearch",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.websearch.reload())
const loaded = { entries: yield* config.entries() }
yield* ctx.websearch.transform((websearch) => {
const selection = Config.latest(loaded.entries, "websearch")
if (selection === false) websearch.default.set(false)
if (selection) websearch.default.set(selection.provider)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.websearch.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+3 -2
View File
@@ -37,10 +37,11 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
const fs = yield* FSUtil.Service
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
const configSource = input.type === "path" ? input.path : input.source
const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
let out = ""
let cursor = 0
for (const match of text.matchAll(/\{file:[^}]+\}/g)) {
for (const match of matches) {
const token = match[0]
const index = match.index
out += text.slice(cursor, index)
@@ -53,7 +54,7 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
continue
}
const filePath = token.slice("{file:".length, -1)
const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
+15 -14
View File
@@ -51,7 +51,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Cr
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
@@ -62,30 +62,31 @@ const layer = Layer.effect(
value: decode(row.value),
})
}
const storedRows = (rows: ReadonlyArray<typeof CredentialTable.$inferSelect>) =>
rows.flatMap((row) => {
const credential = stored(row)
return credential ? [credential] : []
})
return Service.of({
all: Effect.fn("Credential.all")(() =>
db
all: Effect.fn("Credential.all")(function* () {
return (yield* db
.select()
.from(CredentialTable)
.orderBy(asc(CredentialTable.time_created))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
list: Effect.fn("Credential.list")((integrationID) =>
db
.pipe(Effect.orDie)).flatMap((row) => {
const credential = stored(row)
return credential ? [credential] : []
})
}),
list: Effect.fn("Credential.list")(function* (integrationID) {
return (yield* db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, integrationID))
.orderBy(asc(CredentialTable.time_created))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
.pipe(Effect.orDie)).flatMap((row) => {
const credential = stored(row)
return credential ? [credential] : []
})
}),
get: Effect.fn("Credential.get")(function* (id) {
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
return row ? stored(row) : undefined
+25 -14
View File
@@ -90,20 +90,31 @@ export function applyOnly(db: Database, input: Migration[]) {
)
}),
)
const run =
migration.foreignKeys !== false
? apply
: Effect.gen(function* () {
// Durable Object SQLite rejects the foreign_keys toggle; the closest
// allowlisted relaxation is deferring enforcement to transaction commit.
const relaxForeignKeys = supportsForeignKeyToggle
? db.run(sql`PRAGMA foreign_keys = OFF`)
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
yield* relaxForeignKeys
yield* apply.pipe(Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)))
})
yield* run.pipe(
if (migration.foreignKeys !== false) {
yield* apply.pipe(
Effect.tapError((error) =>
Effect.logError("database migration failed", {
migration: migration.id,
durationMs: Date.now() - started,
error,
}),
),
)
yield* Effect.logInfo("database migration completed", {
migration: migration.id,
durationMs: Date.now() - started,
})
continue
}
// Durable Object SQLite rejects the foreign_keys toggle; the closest
// allowlisted relaxation is deferring enforcement to transaction commit.
const relaxForeignKeys = supportsForeignKeyToggle
? db.run(sql`PRAGMA foreign_keys = OFF`)
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
yield* relaxForeignKeys
yield* apply.pipe(
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
Effect.tapError((error) =>
Effect.logError("database migration failed", {
migration: migration.id,
+22 -2
View File
@@ -1,4 +1,5 @@
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore } from "effect"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
@@ -166,7 +167,26 @@ const make = (options: Config) =>
}),
})
const connection = Sqlite.makeConnection(run, runValues, {})
const connection = identity<Connection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
})
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
@@ -481,7 +481,7 @@ export function transformSession(input: TransformInput): TransformResult {
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const db = (yield* Database.Service).db
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const state = yield* readState(db)
if (runtimeState.status === "running") return runtimeState
@@ -523,7 +523,7 @@ function updateProgress(progress: Progress) {
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const db = (yield* Database.Service).db
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
+9 -6
View File
@@ -4,13 +4,16 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
// Only build the location service map if it's actually needed
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
return LayerNode.compile(root, replacements)
let allReplacements = replacements
const locationMap = buildLocationServiceMap(replacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
// Only build the location service map if it's actually needed
if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) {
const locationMap = buildLocationServiceMap(replacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]])
}
return LayerNode.compile(root, allReplacements)
}
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
@@ -23,13 +23,13 @@ const environmentValue = (environment: Environment, name: string) =>
const bypassesProxy = (url: URL, value: string | undefined) => {
if (!value) return false
const port = url.port || (url.protocol === "wss:" ? "443" : "80")
const hostname = url.hostname.toLowerCase()
return value.split(/[\s,]+/).some((entry) => {
if (!entry) return false
if (entry === "*") return true
const match = entry.match(/^(.+?):(\d+)$/)
if (match?.[2] && match[2] !== port) return false
const host = (match?.[1] ?? entry).toLowerCase().replace(/^\*/, "")
const hostname = url.hostname.toLowerCase()
return host.startsWith(".") ? hostname.endsWith(host) : hostname === host
})
}
+2 -2
View File
@@ -4,13 +4,13 @@ import { Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
const EVENT_TYPES = new Set(["agent.updated", "catalog.updated", "command.updated", "config.updated"])
const Types = new Set(["agent.updated", "catalog.updated", "command.updated", "config.updated"])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
const unsubscribe = yield* bus.listen((event) =>
EVENT_TYPES.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void,
Types.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
}),
+2 -2
View File
@@ -13,10 +13,10 @@ export const cleanup = Effect.fn("FileRetention.cleanup")(function* (
files,
(file) =>
Effect.gen(function* () {
const info = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime = info && Option.getOrUndefined(info.mtime)
if (!mtime || mtime.getTime() >= cutoff) return
yield* fs.remove(file).pipe(Effect.ignore)
yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
}),
{ concurrency: 8, discard: true },
)
+1 -1
View File
@@ -70,7 +70,7 @@ const baseLayer = Layer.effect(
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory }
return { absolute, real, directory: location.directory, root }
})
return Service.of({
find: search.find,
@@ -35,12 +35,14 @@ const layer = Layer.effect(
Effect.gen(function* () {
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)) : undefined
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs) return { path: path.join(vcs, "HEAD"), aliases: [".git", vcs, ...(resolved ? [resolved] : [])] }
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.orElseSucceed(() => store))
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
return { path: path.join(vcs, "branch"), aliases: [".hg", vcs] }
}
}).pipe(
+15 -10
View File
@@ -109,11 +109,16 @@ export const layer = Layer.effect(
},
)
const requireEntry = Effect.fn("Form.requireEntry")((id: ID) =>
Cache.getSuccess(forms, id).pipe(
Effect.flatMap((entry) => Effect.fromOption(entry, () => new NotFoundError({ id }))),
),
)
const find = Effect.fn("Form.find")(function* (id: ID) {
return yield* Cache.getSuccess(forms, id).pipe(
Effect.flatMap((entry) =>
Option.match(entry, {
onNone: () => Effect.fail(new NotFoundError({ id })),
onSome: Effect.succeed,
}),
),
)
})
const create = Effect.fn("Form.create")((input: CreateInput) =>
Effect.uninterruptible(
@@ -146,7 +151,7 @@ export const layer = Layer.effect(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const form = yield* create(input)
const entry = yield* requireEntry(form.id).pipe(Effect.orDie)
const entry = yield* find(form.id).pipe(Effect.orDie)
return yield* restore(Deferred.await(entry.deferred)).pipe(
Effect.onInterrupt(() => Effect.ignore(cancel(form.id))),
)
@@ -155,7 +160,7 @@ export const layer = Layer.effect(
)
const get = Effect.fn("Form.get")(function* (id: ID) {
return (yield* requireEntry(id)).form
return (yield* find(id)).form
})
const list = Effect.fn("Form.list")(function* (input?: ListInput) {
@@ -167,13 +172,13 @@ export const layer = Layer.effect(
})
const state = Effect.fn("Form.state")(function* (id: ID) {
return (yield* requireEntry(id)).state
return (yield* find(id)).state
})
const reply = Effect.fn("Form.reply")((input: ReplyInput) =>
Effect.uninterruptible(
Effect.gen(function* () {
const entry = yield* requireEntry(input.id)
const entry = yield* find(input.id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
const invalid = validateAnswer(entry.form.fields, input.answer)
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
@@ -192,7 +197,7 @@ export const layer = Layer.effect(
const cancel = Effect.fn("Form.cancel")((id: ID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const entry = yield* requireEntry(id)
const entry = yield* find(id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
const next: TerminalState = { status: "cancelled" }
yield* bus.publish(Form.Event.Cancelled, { id, sessionID: entry.form.sessionID })
+62 -30
View File
@@ -9,7 +9,6 @@ import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { gitExecutable } from "./util/git-executable.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -172,7 +171,7 @@ const layer = Layer.effect(
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
Effect.catch(() => Effect.succeed(undefined)),
)
if (!dotgit) return undefined
@@ -315,7 +314,7 @@ const layer = Layer.effect(
) {
const result = yield* proc
.run(
ChildProcess.make(gitExecutable, repositoryArgs(repository, args), {
ChildProcess.make("git", repositoryArgs(repository, args), {
cwd: repository.worktree,
env: options?.env,
extendEnv: true,
@@ -347,9 +346,17 @@ const layer = Layer.effect(
gitDirectory: AbsolutePath
seed?: Repository
}) {
const operationError = (message: string) => (cause: unknown) =>
new OperationError({ operation: "create", directory: input.gitDirectory, message, cause })
yield* fs.ensureDir(input.gitDirectory).pipe(Effect.mapError(operationError("Failed to create Git storage")))
yield* fs.ensureDir(input.gitDirectory).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: input.gitDirectory,
message: "Failed to create Git storage",
cause,
}),
),
)
const repository = new Repository({
worktree: input.worktree,
gitDirectory: input.gitDirectory,
@@ -364,20 +371,48 @@ const layer = Layer.effect(
yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, {
flag: "a",
})
}).pipe(Effect.mapError(operationError("Failed to configure Git storage")))
}).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: input.gitDirectory,
message: "Failed to configure Git storage",
cause,
}),
),
)
if (!input.seed) return repository
yield* fs
.ensureDir(path.join(input.gitDirectory, "objects", "info"))
.pipe(Effect.mapError(operationError("Failed to configure shared Git objects")))
yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: input.gitDirectory,
message: "Failed to configure shared Git objects",
cause,
}),
),
)
yield* fs
.writeFileString(
path.join(input.gitDirectory, "objects", "info", "alternates"),
path.join(input.seed.commonDirectory, "objects") + "\n",
)
.pipe(Effect.mapError(operationError("Failed to configure shared Git objects")))
.pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: input.gitDirectory,
message: "Failed to configure shared Git objects",
cause,
}),
),
)
yield* fs
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
.pipe(Effect.ignore)
.pipe(Effect.catch(() => Effect.void))
return repository
})
@@ -404,7 +439,7 @@ const layer = Layer.effect(
? new Set(
(yield* repositoryOperation("refresh", input.ignores, ["check-ignore", "--no-index", "--stdin", "-z"], {
stdin: candidates.join("\0") + "\0",
}).pipe(Effect.orElseSucceed(() => ({ text: "", stderr: "" })))).text
}).pipe(Effect.catch(() => Effect.succeed({ text: "", stderr: "" })))).text
.split("\0")
.filter(Boolean),
)
@@ -419,7 +454,7 @@ const layer = Layer.effect(
Effect.map((info) =>
info.type === "File" && Number(info.size) > maximum ? RelativePath.make(item) : undefined,
),
Effect.orElseSucceed(() => undefined),
Effect.catch(() => Effect.succeed(undefined)),
),
{ concurrency: 8 },
)).filter((item): item is RelativePath => item !== undefined)
@@ -450,14 +485,10 @@ const layer = Layer.effect(
if (!input.paths.length) return new Set<RelativePath>()
const result = yield* proc
.run(
ChildProcess.make(
gitExecutable,
repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]),
{
cwd: input.repository.worktree,
extendEnv: true,
},
),
ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), {
cwd: input.repository.worktree,
extendEnv: true,
}),
{ stdin: input.paths.join("\0") + "\0" },
)
.pipe(
@@ -575,7 +606,7 @@ const layer = Layer.effect(
)
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
const entry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
const text = (yield* repositoryOperation("restore", repository, [
"ls-tree",
"-z",
@@ -583,14 +614,15 @@ const layer = Layer.effect(
"--",
file,
])).text.replace(/\0$/, "")
if (!text) return false
if (!/^\d+\s+\w+\s+[0-9a-f]+\t/.test(text))
if (!text) return
const match = text.match(/^(\d+)\s+\w+\s+([0-9a-f]+)\t/)
if (!match)
return yield* new OperationError({
operation: "restore",
directory: repository.worktree,
message: `Invalid tree entry for ${file}`,
})
return true
return { mode: match[1], object: match[2] }
})
const restore = Effect.fn("Git.tree.restore")(
@@ -601,7 +633,7 @@ const layer = Layer.effect(
input.files,
([file, tree]) =>
Effect.gen(function* () {
if (yield* hasEntry(input.repository, tree, file)) {
if (yield* entry(input.repository, tree, file)) {
yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
return
}
@@ -630,7 +662,7 @@ const layer = Layer.effect(
cwd = repository.worktree,
) {
const result = yield* proc
.run(ChildProcess.make(gitExecutable, args, { cwd, extendEnv: true, stdin: "ignore" }))
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
.pipe(
Effect.mapError(
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
@@ -720,14 +752,14 @@ interface Result {
function run(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
execute(cwd, proc)(args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
execute(cwd, proc)(args).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, text: "", stderr: "" })))
}
function execute(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
proc
.run(
ChildProcess.make(gitExecutable, args, {
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
+2 -1
View File
@@ -42,7 +42,8 @@ export { createID as create }
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
return Number(BigInt(`0x${hex}`) / 0x1000n)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
}
export * as Identifier from "./id.js"
+2 -2
View File
@@ -695,11 +695,11 @@ const layer = Layer.effect(
?.methods.find((method) => method.type === "key")
if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
const answer = input.answer ?? {}
if (method.form) {
if (method.type === "key" && method.form) {
const invalid = Form.validateFields(method.form) ?? Form.validateAnswer(method.form, answer)
if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) })
}
if (!method.form && Object.keys(answer).length > 0) {
if (method.type === "key" && !method.form && Object.keys(answer).length > 0) {
return yield* new AuthorizationError({ cause: new Error("Key method does not accept a form answer") })
}
yield* credentials.create({
+1 -1
View File
@@ -36,7 +36,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/KV
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const { db } = yield* Database.Service
return Service.of({
get: Effect.fn("KV.get")(function* (key) {
return (yield* db

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