Compare commits

...

39 Commits

Author SHA1 Message Date
rekram1-node a5535b2044 fix(www): generate CLI schema without tracking artifacts 2026-08-25 21:54:39 +00:00
Filip 66a790c624 chore(www): regenerate documentation artifacts (#45077) 2026-08-25 23:33:01 +02:00
Aiden Cline 0ae3aac317 fix(ai): replay responses history independently of storage (#45050) 2026-08-25 16:28:22 -05:00
Dax eb1ac54d73 feat(tui): support multiple integration accounts (#45072) 2026-08-25 17:03:16 -04:00
opencode-agent[bot] d2100a51f1 feat(cli): restore debug paths command (#45063)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-25 23:01:43 +02:00
opencode-agent[bot] d4803ffe38 feat(cli): publish generated CLI configuration schema (#45070)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 22:49:45 +02:00
Kit Langton db7837814c fix(project): refresh renamed projects across live clients (#45043) 2026-08-25 20:13:10 +00:00
opencode-agent[bot] 543a4f4912 fix(ci): preserve required e2e matrix checks (#45065)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 22:11:43 +02:00
opencode-agent[bot] d562f6df1e fix(core): recover legacy database migration history (#45062)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 22:02:26 +02:00
Aiden Cline 263a442a6c fix(ai): normalize chat tool call ids (#45056) 2026-08-25 14:39:00 -05:00
Filip 82947af8e3 fix(core): ignore SSE comment heartbeats (#43626) 2026-08-25 21:34:08 +02:00
Filip e1afdaac52 fix(core): fall back on oversized websocket requests v2 (#43100) 2026-08-25 21:27:08 +02:00
Aiden Cline a5829431b0 fix(ai): bridge tool results for mistral family models (#45051) 2026-08-25 13:40:35 -05:00
Aiden Cline 1ca82d154c fix(ai): omit empty chat assistant messages (#45046) 2026-08-25 12:56:24 -05:00
James Long bc1f67e518 feat(cli): embed persistent PTY service binaries (#44970) 2026-08-25 13:19:48 -04:00
Kit Langton 1f45962c84 fix(tui): support inline session rename and title regeneration (#45023) 2026-08-25 13:10:22 -04:00
Kit Langton 88242e21a8 fix(tui): stop completed exploration groups from spinning 2026-08-25 13:09:03 -04:00
opencode-agent[bot] 0fd719067d feat(plugin): add permission review hooks (#45003)
Co-authored-by: R44VC0RP <R44VC0RP@users.noreply.github.com>
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-25 16:57:17 +00:00
Aiden Cline c94a4913c0 fix(ai): preserve unencrypted reasoning history (#45032) 2026-08-25 11:44:44 -05:00
opencode-agent[bot] 6c32ba81e2 test: skip unaffected app e2e tests (#45030)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 00:33:14 +08:00
Kit Langton 938a82226a test(tui): synchronize tab close frame before pointer moves (#45017) 2026-08-25 12:30:20 -04:00
Aiden Cline c46b76b58e fix(ai): route response events by output index (#45013) 2026-08-25 11:27:40 -05:00
opencode-agent[bot] fbb3730fdd chore: update nix node_modules hashes 2026-08-25 16:26:05 +00:00
opencode-agent[bot] 96cff7bb7a fix(app): clarify context usage label (#45012)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 00:20:00 +08:00
Kit Langton 578f8d637a fix(server): pin shared Effect platform runtime (#45016) 2026-08-25 12:19:24 -04:00
Kit Langton e5308a988f feat(merman): refine diagram styling (#44815) 2026-08-25 16:07:12 +00:00
opencode-agent[bot] d177f29dba fix(tui): keep autocomplete selection visible (#44983)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-25 21:30:53 +05:30
Aiden Cline 7f51da509b fix(ai): preserve terminal reasoning metadata (#44997) 2026-08-25 10:37:49 -05:00
James Long 004b647311 feat(server): add persistent PTY daemon API (#44969) 2026-08-25 11:31:47 -04:00
Aiden Cline cce86ac166 fix(ai): fail unknown Bedrock stream exceptions (#45004) 2026-08-25 10:08:21 -05:00
Shoubhit Dash b71291c05a refactor(core): move mercurial vcs into internal plugin (#44993) 2026-08-25 20:35:21 +05:30
Shoubhit Dash 0ab2d783e8 refactor(core): move git vcs into internal plugin (#44992) 2026-08-25 20:26:43 +05:30
Aiden Cline 3deac93d27 fix(ai): sanitize outbound provider request surrogates (#44880) 2026-08-25 09:53:47 -05:00
opencode-agent[bot] ed582d1bdb feat(core): run worktree setup scripts (#44455)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-25 22:45:01 +08:00
opencode-agent[bot] db5a10dad1 fix(app): hide close buttons on cramped inactive tabs (#44995)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 22:35:41 +08:00
Aiden Cline ff59a22ff4 fix(ai): reconcile completed response output items (#44893) 2026-08-25 09:32:42 -05:00
Shoubhit Dash 9c1787617c feat(plugin): add vcs provider api (#44979) 2026-08-25 19:37:06 +05:30
opencode-agent[bot] 7601ab9fc4 fix(desktop): hide sessions during removal (#44913)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 21:09:20 +08:00
opencode-agent[bot] 4fb8a6038a feat(app): select worktree base branch (#44906)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-25 21:05:52 +08:00
230 changed files with 13145 additions and 830 deletions
+41 -4
View File
@@ -22,6 +22,36 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
affected:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
strategy:
@@ -133,7 +163,7 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
needs: affected
strategy:
fail-fast: false
matrix:
@@ -144,32 +174,38 @@ jobs:
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
env:
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2' }}
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
steps:
- name: Checkout repository
if: env.E2E_ENABLED == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
if: env.E2E_ENABLED == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
if: env.E2E_ENABLED == 'true'
uses: ./.github/actions/setup-bun
- name: Read Playwright version
if: env.E2E_ENABLED == 'true'
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: env.E2E_ENABLED == 'true'
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -177,23 +213,24 @@ jobs:
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:local
env:
CI: true
timeout-minutes: 30
- name: Upload Playwright artifacts
if: always()
if: always() && env.E2E_ENABLED == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
+1
View File
@@ -686,6 +686,7 @@
"version": "1.18.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-rIg+QSwF0kmPRnlPrpV7IlPTrHAxvoBOjdtoFOcUnKc=",
"aarch64-linux": "sha256-74rMnqiGy7gAUDWr9HWBc6Sn3kxh/hDfm5kp+I4fjok=",
"aarch64-darwin": "sha256-ImASKYxUQDHzH/UuwXUBsMNkitAH5wDYESlevEy9qeA=",
"x86_64-darwin": "sha256-txPhXfZOSoQdNXPfddKSdKqdjEWrb9GkBb38h+pZIuo="
"x86_64-linux": "sha256-3Jx1Q7hl+Y0Log/k2vd5y6dzBpzFKWlhShPESxn1Rm4=",
"aarch64-linux": "sha256-EiiI6g01oBIrExCMAUgT3w82P0fvu4FAJhI32C+ze0I=",
"aarch64-darwin": "sha256-s+w49HRp1+ewtiTaU65tPWjUiO1NQw3kzfemMEEQZb0=",
"x86_64-darwin": "sha256-/Ee5V7pnL/qm3c4ZHeWEjH7FhGVXArXryOugbG5vsz8="
}
}
+14 -2
View File
@@ -1,7 +1,8 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor.js"
import { mergeHttpOptions, type AIError } from "./schema/index.js"
import { sanitizeSurrogates } from "./utils/sanitize.js"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image.js"
import type { AIError } from "./schema/index.js"
export type Execute = RequestExecutor.Interface["execute"]
@@ -26,7 +27,18 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) => request.model.route.generate(request, executor.execute),
generate: (request) =>
request.model.route.generate(
{
...sanitizeSurrogates({
...request,
model: undefined,
http: mergeHttpOptions(request.model.http, request.http),
}),
model: request.model,
},
executor.execute,
),
})
}),
)
+5 -17
View File
@@ -212,11 +212,7 @@ const BedrockEvent = Schema.Struct({
metrics: Schema.optional(Schema.Unknown),
}),
),
internalServerException: Schema.optional(BedrockStreamException),
modelStreamErrorException: Schema.optional(BedrockStreamException),
validationException: Schema.optional(BedrockStreamException),
throttlingException: Schema.optional(BedrockStreamException),
serviceUnavailableException: Schema.optional(BedrockStreamException),
exception: Schema.optional(Schema.Struct({ type: Schema.String, details: BedrockStreamException })),
})
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
@@ -650,22 +646,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
] as const
}
const exception = (
[
["internalServerException", event.internalServerException],
["modelStreamErrorException", event.modelStreamErrorException],
["serviceUnavailableException", event.serviceUnavailableException],
["throttlingException", event.throttlingException],
["validationException", event.validationException],
] as const
).find((entry) => entry[1] !== undefined)
if (exception) {
if (event.exception) {
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
code: exception[0],
message:
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error",
code: event.exception.type,
}),
})
}
@@ -82,7 +82,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
"Failed to parse Bedrock Converse event-stream payload",
)) as Record<string, unknown>
delete parsed.p
out.push({ [eventType]: parsed })
out.push(messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed })
}
return [cursor, out] as const
})
@@ -154,7 +154,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
...observation,
checkpoint: {
protocol: PROTOCOL,
value: { version: VERSION, responseID, request, output: output.slice() } satisfies CheckpointValue,
value: {
version: VERSION,
responseID,
request,
output: event.response?.output ? [...event.response.output] : output.slice(),
} satisfies CheckpointValue,
},
}
}),
+135 -62
View File
@@ -79,10 +79,60 @@ const OpenResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
const OpenResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
id: Schema.String,
})
const OpenResponsesWebSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
)
const OpenResponsesFileSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("file_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
queries: Schema.optional(Schema.Array(Schema.String)),
results: optionalNull(Schema.Array(JsonObject)),
}),
[JsonObject],
)
const OpenResponsesCodeInterpreterCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("code_interpreter_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
code: optionalNull(Schema.String),
container_id: optionalNull(Schema.String),
outputs: optionalNull(Schema.Array(JsonObject)),
}),
[JsonObject],
)
const OpenResponsesMCPCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("mcp_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
server_label: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
output: optionalNull(Schema.String),
error: Schema.optional(Schema.Unknown),
}),
[JsonObject],
)
export const HostedToolItem = Schema.Union([
OpenResponsesWebSearchCall,
OpenResponsesFileSearchCall,
OpenResponsesCodeInterpreterCall,
OpenResponsesMCPCall,
])
export type HostedToolItem = Schema.Schema.Type<typeof HostedToolItem>
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images and files in addition to text.
@@ -111,7 +161,6 @@ export const InputItem = Schema.Union([
phase: Schema.optionalKey(MessagePhase),
}),
OpenResponsesReasoningItem,
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
@@ -124,10 +173,17 @@ export const InputItem = Schema.Union([
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
}),
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| ExtendedHostedToolItem
| {
readonly type: "message"
readonly id?: string
@@ -288,6 +344,7 @@ export const Event = Schema.StructWithRest(
arguments: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -296,6 +353,7 @@ export const Event = Schema.StructWithRest(
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
}),
@@ -315,7 +373,7 @@ export const Event = Schema.StructWithRest(
export type Event = Schema.Schema.Type<typeof Event>
// Which lowered input item a persisted item id is about to be attached to.
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
export type ItemKind = "message" | "reasoning" | "function-call" | "hosted-tool"
export interface Extension {
readonly id: string
@@ -325,6 +383,7 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
// Optional grammar check applied before a persisted item id is resent as
// part of replayed history. Returning false drops the id; every lowered
// item treats a dropped id the same as an absent one.
@@ -340,10 +399,10 @@ export interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly messageItems: ReadonlySet<string>
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
@@ -528,7 +587,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
@@ -551,8 +609,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const hostedToolItems = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<
@@ -587,11 +644,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
@@ -612,24 +664,29 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
if (store !== false && reference && !hostedToolReferences.has(reference))
input.push({ type: "item_reference", id: reference })
if (store === false) {
// The server is not storing this exchange, so the tool outcome has to
// travel in the input. Non-content results degrade to their text form.
const content: ReadonlyArray<Content> =
part.result.type === "content"
const hosted =
part.result.type !== "json"
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
: extension.lowerHostedToolItem?.(part.result.value)
if (acceptsItemID(extension, "hosted-tool", id) && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
hostedToolItems.add(id)
}
continue
}
if (reference) hostedToolReferences.add(reference)
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -654,14 +711,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
}
// With store:false, Responses APIs only accept previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
return input
})
const lowerOptions = (request: LLMRequest) => {
@@ -817,6 +867,9 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
@@ -965,31 +1018,21 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle:
state.store !== false
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
)
: state.lifecycle,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
[event.summary_index]: "can-conclude",
},
},
},
},
events,
NO_EVENTS,
]
}
@@ -1111,30 +1154,49 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
})
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
const reconciled =
event.type === "response.completed"
? yield* Effect.reduce(
event.response?.output ?? [],
() => [state, NO_EVENTS] satisfies StepResult,
([current, events], item) => {
if (
!item.id ||
((item.type !== "function_call" || !current.tools[item.id]) &&
(item.type !== "reasoning" || !current.reasoningItems[item.id]))
)
return Effect.succeed([current, events] satisfies StepResult)
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
)
},
)
: ([state, NO_EVENTS] satisfies StepResult)
const current = reconciled[0]
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(state.id, state.tools)
: { tools: state.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...pending.events]
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
current.hasFunctionCall
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
providerMetadata:
event.response?.id || event.response?.service_tier
? providerMetadata(state, {
? providerMetadata(current, {
responseId: event.response.id,
serviceTier: event.response.service_tier,
})
: undefined,
})
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
@@ -1181,7 +1243,11 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, event: Event) => {
export const step = (state: ParserState, input: Event) => {
const event =
input.item_id && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1223,7 +1289,14 @@ export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && event.item?.id
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
: state,
event,
),
)
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
return event.item_id
@@ -1258,10 +1331,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
messageItems: new Set<string>(),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
export const protocol = Protocol.make({
+28 -7
View File
@@ -278,6 +278,7 @@ interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
readonly toolCallID?: (id: string) => string
}
const lowerTool = (
@@ -304,8 +305,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "function" as const, function: { name } }),
})
const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
id: part.id,
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
id: options.toolCallID?.(part.id) ?? part.id,
type: "function",
function: {
name: part.name,
@@ -381,7 +382,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part))
toolCalls.push(lowerToolCall(part, options))
continue
}
}
@@ -427,7 +428,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: part.id,
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
@@ -437,7 +438,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: part.id,
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
@@ -478,12 +479,30 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const modelID = request.model.id.toLowerCase()
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
const lowering = {
...options,
toolCallID: (id: string) => {
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
return id.slice(0, 40)
return id
},
}
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
const bridgeTools = () => {
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
}
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
if (pendingImages.length === 0) return
bridgeTools()
messages.push({ role: "user", content: pendingImages.splice(0) })
}
for (const message of request.messages) {
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
@@ -526,14 +545,16 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
)
continue
}
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
continue
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, options)
const lowered = yield* lowerToolMessages(message, lowering)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, lowering)))
}
flushImages()
return messages
+47 -8
View File
@@ -7,7 +7,7 @@ import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { optionalArray, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -32,6 +32,40 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("computer_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
pending_safety_checks: Schema.optional(Schema.Array(JsonObject)),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_preview_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: optionalNull(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
revised_prompt: optionalNull(Schema.String),
}),
[JsonObject],
),
])
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
const OpenAIResponsesToolChoice = Schema.Union([
@@ -41,6 +75,7 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -54,21 +89,21 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
// Replayed items are paired with stored server state by id, so a foreign or
// synthetic token can fail request validation even when `call_id` pairing is
// intact. Only resend ids in each item kind's own grammar; hosted tool
// references keep generic validation because every hosted tool mints its own
// items keep generic validation because every hosted tool mints its own
// prefix. The same allowlist approach codex uses before resending history
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
message: ["msg_"],
reasoning: ["rs_"],
"function-call": ["fc_"],
// Every hosted tool mints its own id prefix, so references keep generic
// validation only.
reference: [],
// Every hosted tool mints its own id prefix, so items keep generic validation.
"hosted-tool": [],
}
const extension = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
const prefixes = ITEM_ID_PREFIXES[kind]
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
@@ -105,6 +140,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
: { type: "function" as const, name },
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
@@ -112,7 +149,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return {
return yield* decodeBody({
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
@@ -123,7 +160,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
} satisfies OpenAIResponsesBody
})
})
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
@@ -166,7 +203,9 @@ const HOSTED_TOOLS = {
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
+41 -1
View File
@@ -1,15 +1,52 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
const XAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("x_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
}),
[JsonObject],
),
])
const XAIResponsesBody = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, XAIResponsesHostedToolItem])),
stream: Schema.Literal(true),
})
const extension = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
@@ -35,7 +72,10 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
export const protocol = Protocol.make({
id: ADAPTER,
body: OpenResponses.protocol.body,
body: {
schema: XAIResponsesBody,
from: fromRequest,
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
+3 -1
View File
@@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
@@ -400,7 +401,8 @@ export function make<Body, Prepared, Frame, Event, State>(
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const original = applyCachePolicy(resolveRequestOptions(request))
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const route = resolved.model.route
const body = yield* route.body
+1
View File
@@ -155,6 +155,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
+12
View File
@@ -0,0 +1,12 @@
import { isRecord } from "./record.js"
export const sanitizeSurrogates = <T>(value: T): T => {
if (typeof value === "string") return value.toWellFormed() as T
if (Array.isArray(value)) return value.map(sanitizeSurrogates) as T
if (value instanceof Uint8Array || value instanceof Error) return value
if (isRecord(value))
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key.toWellFormed(), sanitizeSurrogates(entry)]),
) as T
return value
}
+68 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src/index.js"
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
@@ -247,6 +247,73 @@ describe("request option precedence", () => {
}),
)
it.effect("sanitizes outbound JSON without an HTTP overlay", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "hello \uD800 \u{1F600}",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [{ role: "user", content: "hello \uFFFD \u{1F600}" }],
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("sanitizes unpaired surrogates throughout outbound JSON", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
system: "system \uD800 \u{1F600}",
messages: [
Message.user("user \uDC00"),
Message.assistant([
Message.text("assistant \uD800"),
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "input \uDC00" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: { output: "result \uD800" } }),
],
http: { body: { metadata: { "key\uD800": ["overlay \uDC00", "valid \u{1F600}"] } } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [
{ role: "system", content: "system \uFFFD \u{1F600}" },
{ role: "user", content: "user \uFFFD" },
{
role: "assistant",
content: "assistant \uFFFD",
tool_calls: [{ function: { arguments: '{"query":"input \uFFFD"}' } }],
},
{ role: "tool", content: '{"output":"result \uFFFD"}' },
],
metadata: { "key\uFFFD": ["overlay \uFFFD", "valid \u{1F600}"] },
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
File diff suppressed because one or more lines are too long
@@ -716,6 +716,32 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("ignores unknown normal stream events", () =>
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
eventFrame("futureEvent", { message: "Ignore this" }),
eventFrame("messageStop", { stopReason: "end_turn" }),
])
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("fails unknown stream exceptions after message stop", () =>
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
eventFrame("messageStop", { stopReason: "end_turn" }),
exceptionFrame("futureException", { message: "A future provider failure" }),
])
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "A future provider failure" })
}),
)
it.effect("classifies throttlingException as a rate limit", () =>
Effect.gen(function* () {
const body = concat([
@@ -85,6 +85,28 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits empty and whitespace-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Before."),
Message.assistant([]),
Message.assistant(""),
Message.assistant(" \n\t "),
Message.assistant("After."),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before." },
{ role: "assistant", content: "After." },
])
}),
)
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -366,6 +388,35 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
Effect.gen(function* () {
const id = `call_${"a".repeat(48)}`
const models = [
model,
Azure.configure({ baseURL: "https://opencode-test.openai.azure.com/openai/", apiKey: "test" }).chat("gpt-4o"),
]
yield* Effect.forEach(models, (selected) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "Sunny" }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: id.slice(0, 40) }] },
{ role: "tool", tool_call_id: id.slice(0, 40) },
])
}),
)
}),
)
it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () {
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
@@ -431,6 +482,30 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("bridges image tool results before their synthetic user message when required", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }),
messages: [
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }],
},
}),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"])
expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -238,6 +238,85 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("normalizes tool call IDs for the selected model family", () =>
Effect.gen(function* () {
const longID = `call_${"a".repeat(48)}`
const cases = [
{ provider: "custom", model: "mistral-small", id: "toolu_01CBhTTz95qkd9LJMdC9sf8t", expected: "toolu01CB" },
{ provider: "custom", model: "devstral-small", id: "abc", expected: "abc000000" },
{ provider: "custom", model: "codestral-latest", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "pixtral-large", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "open-mixtral-8x22b", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "gateway", model: "anthropic/claude-sonnet-4", id: "call|item/+", expected: "call_item__" },
{ provider: "gateway", model: "openai/gpt-4o", id: longID, expected: longID.slice(0, 40) },
{ provider: "custom", model: "ordinary-model", id: "call|item/+", expected: "call|item/+" },
{ provider: "mistral", model: "zai-glm-5-2", id: "call_long_identifier", expected: "call_long_identifier" },
]
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAICompatibleChat.route
.with({ provider: item.provider, endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: item.model }),
messages: [
Message.assistant([ToolCallPart.make({ id: item.id, name: "lookup", input: {} })]),
Message.tool({ id: item.id, name: "lookup", result: { type: "content", value: [] } }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: item.expected }] },
{ role: "tool", tool_call_id: item.expected },
])
}),
)
}),
)
it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () =>
Effect.gen(function* () {
const cases = [
{ id: "mistral-small", bridge: true },
{ id: "devstral-small", bridge: true },
{ id: "codestral-latest", bridge: true },
{ id: "pixtral-large", bridge: true },
{ id: "open-mixtral-8x22b", bridge: true },
{ id: "ordinary-model", bridge: false },
{ id: "ordinary-model", override: true, bridge: true },
{ id: "mistral-small", override: false, bridge: false },
] as const
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const selected = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({
id: item.id,
compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined,
})
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
Message.user("What next?"),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(
item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"],
)
if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
}),
)
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -225,6 +225,143 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("replays only shared hosted tool items", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const items = [
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ type: "x_search_call", id: "x_search_1", status: "completed" },
{ type: "future_call", id: "future_1", status: "completed" },
{ type: "file_search_call", id: "fs_1", queries: "not-an-array" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openresponses: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[1]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[3]) }] },
])
}),
)
it.effect("routes response deltas by output index", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
])
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"par' },
{
type: "response.completed",
response: {
output: [
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"complete"}',
},
],
},
},
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "item_1" } },
})
}),
)
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
},
{ type: "response.reasoning_summary_text.delta", item_id: "rs_raw", delta: "Thinking" },
{
type: "response.completed",
response: {
output: [{ type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" }],
},
},
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
})
}),
)
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
Effect.gen(function* () {
const model = configure({
File diff suppressed because it is too large Load Diff
+102 -7
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
@@ -14,9 +14,9 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
describe("xAI Responses route", () => {
it.effect("extends the Open Responses baseline directly", () =>
it.effect("composes the Open Responses baseline with xAI extensions", () =>
Effect.gen(function* () {
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).not.toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
@@ -70,16 +70,106 @@ describe("xAI Responses route", () => {
}),
)
it.effect("parses xAI hosted tool items", () =>
it.effect("routes xAI reasoning summaries by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
type: "response.output_item.added",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1" },
},
{
type: "response.reasoning_summary_text.delta",
output_index: 3,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.reasoning).toBe("Considering.")
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
})
}),
)
it.effect("replays xAI hosted tool items when continuing with the same provider", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "tool-result",
id: "x_search_1",
name: "x_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { xai: { itemId: "x_search_1" } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([item])
}),
)
it.effect("replays shared and xAI hosted tool items but rejects OpenAI extensions", () =>
Effect.gen(function* () {
const items = [
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ type: "image_generation_call", id: "ig_1", status: "completed", result: "AQID" },
{ type: "computer_call", id: "computer_1", status: "completed" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { xai: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
items[1],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
])
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { id: "response_1" } },
),
),
@@ -91,6 +181,11 @@ describe("xAI Responses route", () => {
name: "x_search",
input: { query: "news" },
providerExecuted: true,
providerMetadata: { xai: { itemId: "x_search_1" } },
})
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
result: { type: "json", value: item },
providerMetadata: { xai: { itemId: "x_search_1" } },
})
}),
)
@@ -78,6 +78,31 @@ describe("Z.ai Images", () => {
),
)
it.effect("sanitizes unpaired surrogates in outbound image requests", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"),
prompt: "A red circle \uD800 on a white background \u{1F600}",
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toMatchObject({
prompt: "A red circle \uFFFD on a white background \u{1F600}",
metadata: { source: "default\uFFFD" },
})
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
),
)
it.effect("lets raw native options override aliases", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test" }).image("model"),
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_workspace_branch"
const directory = "C:/OpenCode/WorkspaceBranch"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("selects a base branch for a new workspace", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_workspace_branch",
worktree: directory,
vcs: "git",
name: "workspace-branch",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
await expect(selected).toBeVisible()
await selected.click()
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
})
@@ -68,6 +68,41 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
await page.setViewportSize({ width: 360, height: 720 })
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, sessionC }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
{ type: "session", server, sessionId: sessionC },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, sessionC: sessionC.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabB).toBeVisible()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.locator(`a[href="${hrefB}"]`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
})
function session(id: string, title: string) {
return {
id,
+1
View File
@@ -100,6 +100,7 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
+2
View File
@@ -21,6 +21,7 @@ export interface MockServerConfig {
cursor?: string
}
vcsDiff?: unknown[]
vcsBranches?: string[]
messageDelay?: number
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
@@ -296,6 +297,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
vcs: () =>
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsBranches: () => Effect.succeed({ location: location(config), data: config.vcsBranches ?? ["main"] }),
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
fsList: (ctx) =>
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
+4 -16
View File
@@ -5,7 +5,6 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
@@ -43,7 +42,6 @@ export function createHomeSessionsController(home: HomeController) {
const dialog = useDialog()
const language = useLanguage()
const queryClient = useQueryClient()
const [removed, setRemoved] = createStore({ keys: [] as string[] })
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
if (!selected) return
@@ -70,10 +68,9 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
ctx.data.session.apply(
mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()),
),
HOME_SESSION_LIMIT,
Date.now(),
@@ -192,15 +189,9 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
return ctx.sdk.api.session
.remove({ sessionID: session.id })
return ctx.data.session
.remove(session.id)
.then(() => {
const removedIDs = new Set(ids)
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((item) => !removedIDs.has(item.id)),
)
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
directory: session.location.directory,
@@ -216,9 +207,6 @@ export function createHomeSessionsController(home: HomeController) {
return false
})
.finally(() => {
// Always refetch: the pre-mutation cancel may have aborted an
// in-flight index fetch, and a failed delete must not leave the
// index unloaded either.
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
})
}
@@ -20,6 +20,7 @@ import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/
export function createNewSessionComposerAdapter(props: {
draftID: string
worktree: () => string
branch: () => string | undefined
submitted: () => void
}) {
const route = useSessionKey()
@@ -48,6 +49,7 @@ export function createNewSessionComposerAdapter(props: {
const sessionDirectory = await resolveSessionDirectory({
projectDirectory,
worktree,
branch: props.branch(),
data,
serverSDK,
language,
@@ -73,7 +75,7 @@ export function createNewSessionComposerAdapter(props: {
return { ok: false as const, error }
},
)
const afterCreation = async <T,>(run: () => Promise<T>) => {
const afterCreation = async <T>(run: () => Promise<T>) => {
const result = await creation
if (!result.ok) throw result.error
return run()
@@ -83,7 +85,7 @@ export function createNewSessionComposerAdapter(props: {
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined })
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,
@@ -161,6 +163,7 @@ function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["
async function resolveSessionDirectory(input: {
projectDirectory: string
worktree: string
branch?: string
data: ReturnType<typeof useData>
serverSDK: ReturnType<typeof useServerSDK>
language: ReturnType<typeof useLanguage>
@@ -172,6 +175,7 @@ async function resolveSessionDirectory(input: {
.create({
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
strategy: "git",
branch: input.branch,
directory: getDirectory(
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
),
@@ -39,6 +39,7 @@ export function createComposerProjectControls(props: { draftId: string }) {
server: ServerConnection.key(connection),
directory: worktree,
worktree: undefined,
branch: undefined,
})
}
const addProject = (title: string, serverKey?: string) => {
+7 -2
View File
@@ -21,15 +21,20 @@ export default function NewSessionPage(props: { draftId: string }) {
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const workspace = createNewSessionWorkspaceController({
selected: () => draftTab()?.worktree,
setSelected: (worktree) => {
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
setSelectedWorktree: (worktree) => {
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
},
setSelectedBranch: (branch) => {
if (search.draftId) tabs.updateDraft(search.draftId, { branch })
},
onViewAll: openWorkspaces,
})
const composer = createNewSessionComposerAdapter({
draftID: props.draftId,
worktree: workspace.selection.value,
branch: workspace.bar.branch,
submitted: workspace.selection.remember,
})
const model = createComposerModel(composer.adapter)
+3
View File
@@ -69,9 +69,12 @@ export function NewSessionView(props: {
value={props.workspace.selection.value()}
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branches={props.workspace.project.branches()}
branch={props.workspace.bar.branch()}
onboarding={onboardingReady() && !onboarding.used}
onChange={select}
onCreate={props.workspace.selection.create}
onSearch={props.workspace.project.searchBranches}
onDone={props.composer.restoreFocus}
onViewAll={props.workspace.project.openAll}
/>
@@ -65,6 +65,17 @@ describe("new session workspace selection", () => {
).toBe(undefined)
})
test("uses a selected branch for a new workspace", () => {
expect(
resolveNewSessionBranch({
worktree: "create",
directory: "/project/feature",
createBranch: "release",
worktreeBranch: () => "feature",
}),
).toBe("release")
})
test("uses location VCS state when the project inventory is stale", () => {
expect(resolveNewSessionGit({ branch: "dev" })).toBe(true)
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
@@ -1,4 +1,6 @@
import { createEffect, createMemo } from "solid-js"
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
@@ -32,8 +34,10 @@ export function normalizeNewSessionWorktree(value: string, directory: string, pr
export function resolveNewSessionBranch(input: {
worktree: string
directory: string
createBranch?: string
worktreeBranch: (worktree: string) => string | undefined
}) {
if (input.worktree === "create" && input.createBranch) return input.createBranch
const directory = input.worktree === "main" || input.worktree === "create" ? input.directory : input.worktree
return input.worktreeBranch(directory)
}
@@ -43,14 +47,18 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri
}
export function createNewSessionWorkspaceController(input: {
selected: () => string | undefined
setSelected: (worktree: string | undefined) => void
selectedWorktree: () => string | undefined
selectedBranch: () => string | undefined
setSelectedWorktree: (worktree: string | undefined) => void
setSelectedBranch: (branch: string | undefined) => void
onViewAll: () => void
}) {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const data = useData()
const settings = useSettings()
const [state, setState] = createStore({ search: "" })
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
const current = projectID ? data.project.get(projectID) : undefined
@@ -64,7 +72,7 @@ export function createNewSessionWorkspaceController(input: {
)
const selected = createMemo(() => {
const project = currentProject()
const worktree = input.selected()
const worktree = input.selectedWorktree()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
@@ -86,6 +94,14 @@ export function createNewSessionWorkspaceController(input: {
}),
)
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
const [branches] = createResource(
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs
.branches({ location: { directory }, search, limit: 50 })
.then((response) => ({ directory, search, data: response.data }))
.catch(() => ({ directory, search, data: [] })),
)
createEffect(() => {
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
() => undefined,
@@ -98,6 +114,7 @@ export function createNewSessionWorkspaceController(input: {
resolveNewSessionBranch({
worktree: value(),
directory: sdk().directory,
createBranch: input.selectedBranch(),
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
}),
)
@@ -116,10 +133,19 @@ export function createNewSessionWorkspaceController(input: {
const current = value()
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
}),
reset: () => input.setSelected(undefined),
reset: () => {
input.setSelectedWorktree(undefined)
input.setSelectedBranch(undefined)
},
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
input.setSelectedBranch(undefined)
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
},
create: (branch: string) => {
input.setSelectedBranch(branch)
input.setSelectedWorktree("create")
remember("create")
},
},
project: {
@@ -129,6 +155,15 @@ export function createNewSessionWorkspaceController(input: {
return project ? workspaceDirectories(project) : []
},
git: visible,
branches: () => {
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
const loaded = branches.latest
const list = loaded?.directory === projectRoot() ? loaded.data : []
return [
...new Set([...list, ...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : [])]),
].slice(0, 50)
},
searchBranches,
openAll: input.onViewAll,
},
bar: {
@@ -1,4 +1,5 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
@@ -10,20 +11,24 @@ export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
branches: string[]
branch?: string
onboarding?: boolean
onChange: (value: string) => void
onCreate: (branch: string) => void
onSearch: (search: string) => void
onDone: () => void
onViewAll: () => void
}) {
const language = useLanguage()
const [search, setSearch] = createSignal("")
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
const query = search().trim().toLowerCase()
const query = search.workspaces.trim().toLowerCase()
if (!query) return props.workspaces
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
})
@@ -37,12 +42,14 @@ export function PromptWorkspaceSelector(props: {
}
const onOpenChange = (open: boolean) => {
if (open) {
setSearch("")
setSearch({ workspaces: "", branches: "" })
props.onSearch("")
return
}
const action = pending
pending = undefined
if (action?.type === "select") props.onChange(action.value)
if (action?.type === "create") props.onCreate(action.branch)
if (action?.type === "viewAll") {
props.onViewAll()
return
@@ -120,21 +127,7 @@ export function PromptWorkspaceSelector(props: {
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<Tooltip
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
@@ -191,11 +184,11 @@ export function PromptWorkspaceSelector(props: {
ref={(element) => {
searchInput = element
}}
value={search()}
value={search.workspaces}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => setSearch(event.currentTarget.value)}
onInput={(event) => setSearch("workspaces", event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
@@ -232,7 +225,94 @@ export function PromptWorkspaceSelector(props: {
</Menu.Portal>
</Menu>
</Tooltip>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
<Show
when={selected() === "create" && props.branch}
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
>
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu
placement="bottom"
gutter={4}
onOpenChange={(open) => {
onOpenChange(open)
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
}}
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
branchSearchInput = element
}}
value={search.branches}
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => {
setSearch("branches", event.currentTarget.value)
props.onSearch(event.currentTarget.value)
}}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
<Show when={search.branches.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setSearch("branches", "")
props.onSearch("")
}}
aria-label={language.t("common.clear")}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<div class="max-h-[224px] overflow-y-auto">
<Menu.RadioGroup value={props.branch}>
<For each={props.branches}>
{(branch) => (
<Menu.RadioItem
value={branch}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
closeOnSelect
onSelect={() => (pending = { type: "create", branch })}
>
<span class="min-w-0 truncate leading-5">{branch}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</div>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</Show>
</>
)
}
+5 -2
View File
@@ -460,7 +460,8 @@ export const dict = {
"dialog.project.edit.color": "Color",
"dialog.project.edit.color.select": "Select {{color}} color",
"dialog.project.edit.worktree.startup": "Workspace startup script",
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
"dialog.project.edit.worktree.startup.description":
"Runs after creating a new workspace (worktree). Use $OPENCODE_WORKTREE_BASE for the base worktree and $OPENCODE_WORKTREE_PATH for the new worktree.",
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
"dialog.releaseNotes.action.getStarted": "Get started",
@@ -499,7 +500,7 @@ export const dict = {
"context.stats.lastActivity": "Last Activity",
"context.usage.tokens": "Tokens",
"context.usage.usage": "Usage",
"context.usage.usage": "Context Usage",
"context.usage.cost": "Cost",
"context.usage.clickToView": "Click to view context",
"context.usage.view": "View context usage",
@@ -1151,6 +1152,8 @@ export const dict = {
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.createFrom": "Create from branch",
"session.new.workspace.branch.search.placeholder": "Search branches",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search workspaces",
"settings.tab.workspaces": "Workspaces",
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createSessionMutations } from "./data"
const session = { id: "ses_test" } as SessionInfo
test("keeps a successful removal applied until its event arrives", async () => {
const release = Promise.withResolvers<void>()
const mutation = createSessionMutations(async () => release.promise)
const request = mutation.remove(session.id)
expect(mutation.apply([session])).toEqual([])
release.resolve()
await request
expect(mutation.apply([session])).toEqual([])
mutation.deleted(session.id)
expect(mutation.apply([session])).toEqual([session])
})
test("rolls back a failed removal", async () => {
const release = Promise.withResolvers<void>()
const mutation = createSessionMutations(async () => {
await release.promise
throw new Error("offline")
})
const request = mutation.remove(session.id)
expect(mutation.apply([session])).toEqual([])
release.resolve()
await expect(request).rejects.toThrow("offline")
expect(mutation.apply([session])).toEqual([session])
})
+51
View File
@@ -0,0 +1,51 @@
import type { Data } from "@opencode-ai/client/solid"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
type SessionMutation = { readonly id: string; readonly type: "remove"; readonly sessionID: string }
export function createDesktopData(input: { data: Data; remove: (sessionID: string) => Promise<void> }) {
const mutation = createSessionMutations(input.remove)
onCleanup(input.data.on("session.deleted", (event) => mutation.deleted(event.data.sessionID)))
return {
...input.data,
session: {
...input.data.session,
list: () => mutation.apply(input.data.session.list()),
apply: mutation.apply,
remove: mutation.remove,
},
}
}
export function createSessionMutations(remove: (sessionID: string) => Promise<void>) {
const [store, setStore] = createStore({ session: [] as SessionMutation[] })
const clear = (id: string) => {
setStore("session", (current) => current.filter((mutation) => mutation.id !== id))
}
return {
apply(sessions: readonly SessionInfo[]) {
const removed = new Set(
store.session.flatMap((mutation) => (mutation.type === "remove" ? [mutation.sessionID] : [])),
)
return removed.size === 0 ? [...sessions] : sessions.filter((session) => !removed.has(session.id))
},
remove(sessionID: string) {
const mutation = { id: crypto.randomUUID(), type: "remove" as const, sessionID }
setStore("session", (current) => [...current, mutation])
return Promise.resolve()
.then(() => remove(sessionID))
.catch((error) => {
clear(mutation.id)
throw error
})
},
deleted(sessionID: string) {
setStore("session", (current) => current.filter((mutation) => mutation.sessionID !== sessionID))
},
}
}
+6 -1
View File
@@ -11,6 +11,7 @@ import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -134,7 +135,7 @@ function createServerController(
) {
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const data = createData({
const source = createData({
api: () => sdk.api,
event: {
on: sdk.event.on,
@@ -143,6 +144,10 @@ function createServerController(
connection: sdk.connection,
directory: "",
})
const data = createDesktopData({
data: source,
remove: (sessionID) => sdk.api.session.remove({ sessionID }),
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
@@ -168,15 +168,15 @@ export function createTimelineController(input: { session: TimelineSessionSource
const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const success = await serverSDK.api.session
.remove({ sessionID: id })
const removed = removedSessionIDs(data.session.list(), id)
const success = await data.session
.remove(id)
.then(() => true)
.catch((error) => {
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
return false
})
if (!success) return false
const removed = removedSessionIDs(data.session.list(), id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true
@@ -39,7 +39,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
<Button type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</DialogFooter>
@@ -1,7 +1,6 @@
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
@@ -9,7 +8,6 @@ import { type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global"
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
@@ -72,9 +70,14 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
// TODO: Restore project edits when the V2 client exposes a project update API.
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
throw new Error(`Project ${props.project.id} cannot be updated`)
await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
@@ -88,7 +91,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
function submit(event: SubmitEvent) {
event.preventDefault()
if (!supported || save.isPending) return
if (save.isPending) return
save.mutate()
}
@@ -98,7 +101,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
folderName,
defaultName,
save,
supported,
submit,
drop,
dragOver,
+12 -2
View File
@@ -31,9 +31,19 @@ export function migrateTabs(value: unknown): Tab[] {
tab.type === "draft" &&
typeof tab.draftID === "string" &&
typeof tab.directory === "string" &&
(tab.worktree === undefined || typeof tab.worktree === "string")
(tab.worktree === undefined || typeof tab.worktree === "string") &&
(tab.branch === undefined || typeof tab.branch === "string")
) {
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
return [
{
type: tab.type,
server,
draftID: tab.draftID,
directory: tab.directory,
worktree: tab.worktree,
branch: tab.branch,
},
]
}
return []
})
+1
View File
@@ -29,6 +29,7 @@ export type DraftTab = {
server: ServerConnection.Key
directory: string
worktree?: string
branch?: string
}
export type Tab = SessionTab | DraftTab
@@ -143,6 +143,10 @@
display: none;
}
[data-titlebar-tab]:not([data-active="true"]) [data-slot="tab-close"] {
display: none;
}
[data-slot="tab-close"] {
right: auto;
left: 50%;
+19 -1
View File
@@ -9,6 +9,7 @@ import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -78,6 +79,23 @@ const appAssetsPlugin: BunPlugin = {
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
platform: item.os,
arch: item.arch,
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
})
const opencodePtyPlugin: BunPlugin = {
name: "opencode-pty-binary",
setup(build) {
build.onLoad({ filter: /persistent-pty[/\\]pty-binding\.ts$/ }, () => ({
loader: "js",
contents: opencodePty
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
: "export default undefined",
}))
},
}
const simulationInputs = new Set<string>()
const simulationGraphPlugin: BunPlugin = {
name: "opencode-simulation-graph",
@@ -105,7 +123,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+7
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
import { collectFiles } from "./files"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
@@ -18,6 +19,11 @@ export type NodeAsset = {
}
export async function collectNodeAssets(target: NodeTarget) {
const opencodePty = await resolveOpencodePty({
platform: target.platform,
arch: target.arch,
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
})
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
const assets: NodeAsset[] = [
@@ -41,6 +47,7 @@ export async function collectNodeAssets(target: NodeTarget) {
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
...(await collectFiles(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
+102
View File
@@ -0,0 +1,102 @@
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const VERSION = "0.1.5"
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
const SHA256 = {
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
} as const
export type OpencodePtyAsset = {
readonly source: string
readonly version: string
readonly sha256: string
}
type Target = {
readonly platform: string
readonly arch: string
readonly libc?: "glibc" | "musl"
}
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
export function resolveOpencodePty(target: Target) {
const rustTarget = targetName(target)
if (!rustTarget) return Promise.resolve(undefined)
const existing = pending.get(rustTarget)
if (existing) return existing
const result = acquire(rustTarget).catch((error) => {
pending.delete(rustTarget)
throw error
})
pending.set(rustTarget, result)
return result
}
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
const executable = path.join(root, "opencode-pty")
const cached = await readFile(executable).catch(() => undefined)
if (cached)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(cached).digest("hex"),
}
await mkdir(root, { recursive: true })
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
const response = await fetch(`${RELEASE}/${archiveName}`)
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
const archive = new Uint8Array(await response.arrayBuffer())
const actual = createHash("sha256").update(archive).digest("hex")
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
try {
const archivePath = path.join(temporary, archiveName)
await writeFile(archivePath, archive)
run("tar", ["-xzf", archivePath, "-C", temporary])
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
const bytes = await readFile(source)
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
await rename(staged, executable).catch(async (error) => {
await rm(staged, { force: true })
if (!(await readFile(executable).catch(() => undefined))) throw error
})
const installed = await readFile(executable)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(installed).digest("hex"),
}
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function targetName(target: Target): keyof typeof SHA256 | undefined {
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
if (!arch) return undefined
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
if (target.platform === "linux" && target.libc === "musl")
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
return undefined
}
function run(command: string, args: readonly string[]) {
const result = spawnSync(command, args, { stdio: "inherit" })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}
+27
View File
@@ -0,0 +1,27 @@
import { Schema } from "effect"
import { format } from "prettier"
import { Info, SchemaURL } from "../src/config/schema"
const target = process.argv[2]
if (!target) throw new Error("A schema output path is required")
const document = Schema.toJsonSchemaDocument(Info)
const content = await format(
JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: SchemaURL,
...document.schema,
...(Object.keys(document.definitions).length ? { $defs: document.definitions } : {}),
}),
{ parser: "json", printWidth: 120 },
)
if (process.argv.includes("--check")) {
if ((await Bun.file(target).text()) !== content) {
console.error("Generated CLI config schema is stale. Run `bun run generate` from packages/www.")
process.exit(1)
}
process.exit(0)
}
await Bun.write(target, content)
+1
View File
@@ -79,6 +79,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
commands: [
Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "List configuration sources" }),
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
],
}),
Spec.make("console", {
@@ -0,0 +1,17 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
export default Runtime.handler(
Commands.commands.debug.commands.paths,
Effect.fn("cli.debug.paths")(function* () {
const global = yield* Global.Service
process.stdout.write(
Object.entries(global)
.map(([key, value]) => `${key.padEnd(10)} ${value}${EOL}`)
.join(""),
)
}),
)
@@ -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)
}),
)
+4 -2
View File
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { Info } from "./schema"
import { Info, SchemaURL } from "./schema"
export * from "./schema"
@@ -87,7 +87,9 @@ 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.orElseSucceed(() => JSON.stringify({ $schema: SchemaURL }, null, 2)))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
+4 -3
View File
@@ -7,7 +7,7 @@ import { Effect, FileSystem, Option, Schema } from "effect"
import { randomUUID } from "crypto"
import { applyEdits, createScanner, modify, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
import { Info } from "./schema"
import { Info, SchemaURL } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
@@ -100,8 +100,9 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
const kv = yield* readJson(path.join(input.state, "kv.json"))
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const values = migrateV1(legacy, kv ?? {})
if (!Object.keys(values).length) return
const migrated = { $schema: SchemaURL, ...values }
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
if (result.cause === undefined)
+6 -1
View File
@@ -1,5 +1,10 @@
import { Config } from "@opencode-ai/tui/config"
import { Schema } from "effect"
export const Info = Schema.Struct({ ...Config.Info.fields })
export const SchemaURL = "https://opencode.ai/v2/cli.json"
export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({ description: "JSON Schema for CLI configuration" }),
...Config.Info.fields,
})
export type Info = Schema.Schema.Type<typeof Info>
+1
View File
@@ -27,6 +27,7 @@ const Handlers = Runtime.handlers(Commands, {
debug: {
agents: () => import("./commands/handlers/debug/agents"),
config: () => import("./commands/handlers/debug/config"),
paths: () => import("./commands/handlers/debug/paths"),
},
console: {
login: () => import("./commands/handlers/console/login"),
+2
View File
@@ -13,6 +13,7 @@ export function nodeTarget(platform: string, arch: string) {
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
return {
platform: targetPlatform,
@@ -25,6 +26,7 @@ export function nodeTarget(platform: string, arch: string) {
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
fffFfiPackage,
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
opencodePtyAsset,
}
}
@@ -62,6 +62,15 @@ function managedService(options: EnsureOptions) {
}
}
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.experimental.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 })
+70 -2
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Option } from "effect"
import { Effect, FileSystem, Option, Schema } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
@@ -17,6 +17,67 @@ function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service
)
}
test("generates reusable keybind schemas and preserves descriptions and numeric constraints", () => {
const document = Schema.toJsonSchemaDocument(Config.Info)
expect(document).toHaveProperty(["definitions", "TuiKeybind.BindingValue"])
expect(document).toHaveProperty(["schema", "properties", "keybinds", "anyOf", 0, "properties", "app.exit"], {
anyOf: [{ $ref: "#/$defs/TuiKeybind.BindingValue" }, { type: "null" }],
description: "Exit the application",
})
expect(document).toHaveProperty(["schema", "properties", "scroll", "anyOf", 0, "properties", "speed", "anyOf", 0], {
type: "number",
minimum: 0.001,
})
expect(document).toHaveProperty(
["schema", "properties", "attention", "anyOf", 0, "properties", "volume", "anyOf", 0],
{ type: "number", minimum: 0, maximum: 1 },
)
})
test("includes the published schema when creating cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the schema in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, JSON.stringify({ $schema: "https://opencode.ai/v2/cli.json", animations: true }))
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(file).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates tui and kv config into cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(
@@ -73,6 +134,7 @@ test("migrates tui and kv config into cli.json", async () => {
)
expect(config).toMatchObject({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy", mode: "light" },
keybinds: {
leader: "ctrl+o",
@@ -128,8 +190,14 @@ test("migrates before the first update and does not remigrate afterward", async
}),
)
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
expect(config).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
})
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
describe("debug paths command", () => {
test("is included in troubleshooting help", async () => {
const [debug, paths] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "paths", "--help"])])
expect(debug.exitCode).toBe(0)
expect(debug.stdout).toContain("paths")
expect(debug.stdout).toContain("Show global paths (data, config, cache, state)")
expect(paths.exitCode).toBe(0)
expect(paths.stdout).toContain("opencode debug paths [flags]")
})
test("prints resolved global paths without starting a server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-paths-"))
try {
const result = await cli(["debug", "paths"], {
XDG_DATA_HOME: path.join(root, "data"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_STATE_HOME: path.join(root, "state"),
})
const paths = Object.fromEntries(
result.stdout
.trim()
.split("\n")
.map((line) => line.trim().split(/\s+/, 2)),
)
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(paths).toMatchObject({
home: os.homedir(),
data: path.join(root, "data", "opencode"),
config: path.join(root, "config", "opencode"),
cache: path.join(root, "cache", "opencode"),
state: path.join(root, "state", "opencode"),
bin: path.join(root, "cache", "opencode", "bin"),
log: path.join(root, "data", "opencode", "log"),
repos: path.join(root, "data", "opencode", "repos"),
})
expect(paths.tmp).toBeTruthy()
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
})
async function cli(args: string[], env?: Record<string, string>) {
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
cwd: path.join(import.meta.dir, ".."),
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
+1
View File
@@ -8,6 +8,7 @@ test("collects each SEA asset key once", async () => {
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
+6 -2
View File
@@ -120,6 +120,7 @@ function nodePrelude(input: NodeBuildInput) {
input.target.platform === "darwin"
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
: undefined
const opencodePtyAsset = input.target.opencodePtyAsset
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Agent = sdk.Agent
@@ -200,13 +201,17 @@ if (__ocIsSea()) {
const __ocAssetRoot = __ocIsSea()
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
if (__ocIsSea()) {
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
for (const __ocKey of __ocAssetKeys()) {
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
if (__ocExists(__ocTarget)) continue
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
if ((__ocKey === __ocPtySpawnHelper || __ocKey === __ocPersistentPty) && process.platform !== "win32")
__ocChmod(__ocTemporary, 0o755)
try {
__ocRename(__ocTemporary, __ocTarget)
} catch (__ocError) {
@@ -214,8 +219,6 @@ if (__ocIsSea()) {
if (!__ocExists(__ocTarget)) throw __ocError
}
}
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
}
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
@@ -227,6 +230,7 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
try {
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
} catch {}
+177
View File
@@ -1333,6 +1333,15 @@ export type CredentialUpdateOperation<E = never> = (
input: CredentialUpdateInput,
) => Effect.Effect<CredentialUpdateOutput, E>
export type CredentialActivateInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type CredentialActivateOutput = void
export type CredentialActivateOperation<E = never> = (
input: CredentialActivateInput,
) => Effect.Effect<CredentialActivateOutput, E>
export type CredentialRemoveInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -1344,12 +1353,22 @@ export type CredentialRemoveOperation<E = never> = (
export interface CredentialApi<E = never> {
readonly update: CredentialUpdateOperation<E>
readonly activate: CredentialActivateOperation<E>
readonly remove: CredentialRemoveOperation<E>
}
export type ProjectListOutput = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly name?: string | undefined
readonly icon?: Project.Icon | undefined
readonly commands?: Project.Commands | undefined
}
export type ProjectUpdateOutput = Project.Info
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
export type ProjectCurrentInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1358,6 +1377,7 @@ export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) =
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly update: ProjectUpdateOperation<E>
readonly current: ProjectCurrentOperation<E>
}
@@ -1582,6 +1602,152 @@ export interface PtyApi<E = never> {
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
}
export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID }
export type ExperimentalPersistentPtyListOutput = 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 sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}>
export type ExperimentalPersistentPtyListOperation<E = never> = (
input: ExperimentalPersistentPtyListInput,
) => Effect.Effect<ExperimentalPersistentPtyListOutput, E>
export type ExperimentalPersistentPtyCreateInput = {
readonly sessionID: Session.ID
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 ExperimentalPersistentPtyCreateOutput = {
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 sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ExperimentalPersistentPtyCreateOperation<E = never> = (
input: ExperimentalPersistentPtyCreateInput,
) => Effect.Effect<ExperimentalPersistentPtyCreateOutput, E>
export type ExperimentalPersistentPtyShutdownOutput = void
export type ExperimentalPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<
ExperimentalPersistentPtyShutdownOutput,
E
>
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtyGetOutput = {
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 sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ExperimentalPersistentPtyGetOperation<E = never> = (
input: ExperimentalPersistentPtyGetInput,
) => Effect.Effect<ExperimentalPersistentPtyGetOutput, E>
export type ExperimentalPersistentPtyUpdateInput = {
readonly ptyID: Pty.ID
readonly attachmentID?: string | undefined
readonly size: { readonly cols: number; readonly rows: number }
}
export type ExperimentalPersistentPtyUpdateOutput = {
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 sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ExperimentalPersistentPtyUpdateOperation<E = never> = (
input: ExperimentalPersistentPtyUpdateInput,
) => Effect.Effect<ExperimentalPersistentPtyUpdateOutput, E>
export type ExperimentalPersistentPtySnapshotInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtySnapshotOutput = {
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 sessionID: Session.ID
readonly foregroundProcess: string | null
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 ExperimentalPersistentPtySnapshotOperation<E = never> = (
input: ExperimentalPersistentPtySnapshotInput,
) => Effect.Effect<ExperimentalPersistentPtySnapshotOutput, E>
export type ExperimentalPersistentPtyRemoveInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtyRemoveOutput = void
export type ExperimentalPersistentPtyRemoveOperation<E = never> = (
input: ExperimentalPersistentPtyRemoveInput,
) => Effect.Effect<ExperimentalPersistentPtyRemoveOutput, E>
export type ExperimentalPersistentPtyConnectTokenInput = {
readonly ptyID: Pty.ID
readonly "x-opencode-ticket"?: string | undefined
}
export type ExperimentalPersistentPtyConnectTokenOutput = PtyTicket.ConnectToken
export type ExperimentalPersistentPtyConnectTokenOperation<E = never> = (
input: ExperimentalPersistentPtyConnectTokenInput,
) => Effect.Effect<ExperimentalPersistentPtyConnectTokenOutput, E>
export interface ExperimentalApi<E = never> {
readonly persistentPty: {
readonly list: ExperimentalPersistentPtyListOperation<E>
readonly create: ExperimentalPersistentPtyCreateOperation<E>
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
readonly get: ExperimentalPersistentPtyGetOperation<E>
readonly update: ExperimentalPersistentPtyUpdateOperation<E>
readonly snapshot: ExperimentalPersistentPtySnapshotOperation<E>
readonly remove: ExperimentalPersistentPtyRemoveOperation<E>
readonly connectToken: ExperimentalPersistentPtyConnectTokenOperation<E>
}
}
export type ShellListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1664,6 +1830,7 @@ export type WorktreeCreateInput = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly branch?: string | undefined
readonly directory: AbsolutePath
readonly name?: string | undefined
}
@@ -1720,6 +1887,14 @@ export type VcsStatusInput = {
export type VcsStatusOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: VcsStatusInput) => Effect.Effect<VcsStatusOutput, E>
export type VcsBranchesInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}
export type VcsBranchesOutput = { readonly location: Location.Info; readonly data: Vcs.BranchList }
export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effect.Effect<VcsBranchesOutput, E>
export type VcsDiffInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
@@ -1731,6 +1906,7 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly status: VcsStatusOperation<E>
readonly branches: VcsBranchesOperation<E>
readonly diff: VcsDiffOperation<E>
}
@@ -1822,6 +1998,7 @@ export interface AppApi<E = never> {
readonly skill: SkillApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly experimental: ExperimentalApi<E>
readonly shell: ShellApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
+149 -1
View File
@@ -138,9 +138,13 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -192,6 +196,21 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ExperimentalPersistentPtyListInput,
ExperimentalPersistentPtyListOutput,
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
ExperimentalPersistentPtyUpdateOutput,
ExperimentalPersistentPtySnapshotInput,
ExperimentalPersistentPtySnapshotOutput,
ExperimentalPersistentPtyRemoveInput,
ExperimentalPersistentPtyRemoveOutput,
ExperimentalPersistentPtyConnectTokenInput,
ExperimentalPersistentPtyConnectTokenOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -222,6 +241,8 @@ import type {
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -916,6 +937,14 @@ const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialActivate = (raw: RawClient["server.credential"]) => (input: CredentialActivateInput) =>
preserveEffect<CredentialActivateOutput>()(
raw["credential.activate"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) =>
preserveEffect<CredentialRemoveOutput>()(
raw["credential.remove"]({
@@ -926,12 +955,21 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
update: EndpointCredentialUpdate(raw),
activate: EndpointCredentialActivate(raw),
remove: EndpointCredentialRemove(raw),
})
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
params: { projectID: input["projectID"] },
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
preserveEffect<ProjectCurrentOutput>()(
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -939,6 +977,7 @@ const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: Pr
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
list: EndpointProjectList(raw),
update: EndpointProjectUpdate(raw),
current: EndpointProjectCurrent(raw),
})
@@ -1179,6 +1218,100 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
connect: { token: EndpointPtyConnectToken(raw) },
})
const EndpointExperimentalPersistentPtyList =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyListInput) =>
preserveEffect<ExperimentalPersistentPtyListOutput>()(
raw["persistentPty.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyCreate =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyCreateInput) =>
preserveEffect<ExperimentalPersistentPtyCreateOutput>()(
raw["persistentPty.create"]({
params: { sessionID: input["sessionID"] },
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 EndpointExperimentalPersistentPtyShutdown = (raw: RawClient["server.experimental"]) => () =>
preserveEffect<ExperimentalPersistentPtyShutdownOutput>()(
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyGet =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyGetInput) =>
preserveEffect<ExperimentalPersistentPtyGetOutput>()(
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyUpdate =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyUpdateInput) =>
preserveEffect<ExperimentalPersistentPtyUpdateOutput>()(
raw["persistentPty.update"]({
params: { ptyID: input["ptyID"] },
payload: { attachmentID: input["attachmentID"], size: input["size"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtySnapshot =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtySnapshotInput) =>
preserveEffect<ExperimentalPersistentPtySnapshotOutput>()(
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyRemove =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyRemoveInput) =>
preserveEffect<ExperimentalPersistentPtyRemoveOutput>()(
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyConnectToken =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyConnectTokenInput) =>
preserveEffect<ExperimentalPersistentPtyConnectTokenOutput>()(
raw["persistentPty.connectToken"]({
params: { ptyID: input["ptyID"] },
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
persistentPty: {
list: EndpointExperimentalPersistentPtyList(raw),
create: EndpointExperimentalPersistentPtyCreate(raw),
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
get: EndpointExperimentalPersistentPtyGet(raw),
update: EndpointExperimentalPersistentPtyUpdate(raw),
snapshot: EndpointExperimentalPersistentPtySnapshot(raw),
remove: EndpointExperimentalPersistentPtyRemove(raw),
connectToken: EndpointExperimentalPersistentPtyConnectToken(raw),
},
})
const EndpointShellList = (raw: RawClient["server.shell"]) => (input?: ShellListInput) =>
preserveEffect<ShellListOutput>()(
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1248,7 +1381,13 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: Wo
preserveEffect<WorktreeCreateOutput>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
payload: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1300,6 +1439,13 @@ const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusIn
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranchesInput) =>
preserveEffect<VcsBranchesOutput>()(
raw["vcs.branches"]({
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
preserveEffect<VcsDiffOutput>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
@@ -1310,6 +1456,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
get: EndpointVcsGet(raw),
status: EndpointVcsStatus(raw),
branches: EndpointVcsBranches(raw),
diff: EndpointVcsDiff(raw),
})
@@ -1377,6 +1524,7 @@ const adaptClient = (raw: RawClient) => ({
skill: adaptGroupSkill(raw["server.skill"]),
event: adaptGroupEvent(raw["server.event"]),
pty: adaptGroupPty(raw["server.pty"]),
experimental: adaptGroupExperimental(raw["server.experimental"]),
shell: adaptGroupShell(raw["server.shell"]),
reference: adaptGroupReference(raw["server.reference"]),
worktree: adaptGroupWorktree(raw["server.worktree"]),
+3
View File
@@ -4,15 +4,18 @@ export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type GenerateApi = Client["generate"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type PermissionApi = Client["permission"]
export type ProviderApi = Client["provider"]
export type ReferenceApi = Client["reference"]
export type WebSearchApi = Client["websearch"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
export type VcsApi = Client["vcs"]
export interface CatalogApi {
readonly provider: ProviderApi
@@ -132,9 +132,13 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -188,6 +192,21 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ExperimentalPersistentPtyListInput,
ExperimentalPersistentPtyListOutput,
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
ExperimentalPersistentPtyUpdateOutput,
ExperimentalPersistentPtySnapshotInput,
ExperimentalPersistentPtySnapshotOutput,
ExperimentalPersistentPtyRemoveInput,
ExperimentalPersistentPtyRemoveOutput,
ExperimentalPersistentPtyConnectTokenInput,
ExperimentalPersistentPtyConnectTokenOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -218,6 +237,8 @@ import type {
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -1251,6 +1272,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
activate: (input: CredentialActivateInput, requestOptions?: RequestOptions) =>
request<CredentialActivateOutput>(
{
method: "POST",
path: `/api/credential/${encodeURIComponent(input.credentialID)}/activate`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
remove: (input: CredentialRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialRemoveOutput>(
{
@@ -1270,6 +1303,18 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
request<ProjectUpdateOutput>(
{
method: "PATCH",
path: `/api/project/${encodeURIComponent(input.projectID)}`,
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
request<ProjectCurrentOutput>(
{
@@ -1621,6 +1666,108 @@ export function make(options: ClientOptions) {
),
},
},
experimental: {
persistentPty: {
list: (input: ExperimentalPersistentPtyListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyListOutput }>(
{
method: "GET",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: ExperimentalPersistentPtyCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyCreateOutput }>(
{
method: "POST",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/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<ExperimentalPersistentPtyShutdownOutput>(
{
method: "POST",
path: `/api/experimental/persistent-pty/shutdown`,
successStatus: 204,
declaredStatuses: [503, 401, 400],
empty: true,
},
requestOptions,
),
get: (input: ExperimentalPersistentPtyGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyGetOutput }>(
{
method: "GET",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
update: (input: ExperimentalPersistentPtyUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyUpdateOutput }>(
{
method: "PUT",
path: `/api/experimental/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: ExperimentalPersistentPtySnapshotInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtySnapshotOutput }>(
{
method: "GET",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/snapshot`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: ExperimentalPersistentPtyRemoveInput, requestOptions?: RequestOptions) =>
request<ExperimentalPersistentPtyRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
connectToken: (input: ExperimentalPersistentPtyConnectTokenInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyConnectTokenOutput }>(
{
method: "POST",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect-token`,
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
successStatus: 200,
declaredStatuses: [403, 404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
},
shell: {
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
request<ShellListOutput>(
@@ -1736,6 +1883,7 @@ export function make(options: ClientOptions) {
body: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
@@ -1819,6 +1967,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
branches: (input?: VcsBranchesInput, requestOptions?: RequestOptions) =>
request<VcsBranchesOutput>(
{
method: "GET",
path: `/api/vcs/branches`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
diff: (input: VcsDiffInput, requestOptions?: RequestOptions) =>
request<VcsDiffOutput>(
{
+251 -10
View File
@@ -338,6 +338,21 @@ export type Pty = {
exitCode?: number
}
export type PersistentPtyInfo = {
id: string
title: string
command: string
args: Array<string>
cwd: string
status: "running" | "exited"
pid: number
exitCode?: number
sessionID: string
foregroundProcess: string | null
size: { cols: number; rows: number }
output: { head: number; tail: number }
}
export type FormMetadata1 = { [x: string]: any }
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
@@ -393,6 +408,8 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified"
}
export type VcsBranchList = Array<string>
export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
@@ -806,6 +823,24 @@ export type ModelsDevRefreshed = {
data: {}
}
export type CredentialUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "credential.updated"
location?: LocationRef
data: {}
}
export type CredentialSwitched = {
id: string
created: number
metadata?: { [x: string]: any }
type: "credential.switched"
location?: LocationRef
data: { integrationID: string; credentialID: string | null }
}
export type IntegrationUpdated = {
id: string
created: number
@@ -815,15 +850,6 @@ export type IntegrationUpdated = {
data: {}
}
export type IntegrationConnectionUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.connection.updated"
location?: LocationRef
data: { integrationID: string }
}
export type CatalogUpdated = {
id: string
created: number
@@ -996,6 +1022,15 @@ export type PtyDeleted = {
data: { id: string }
}
export type PersistentPtyRemoved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "persistent-pty.removed"
location?: LocationRef
data: { sessionID: string; ptyID: string }
}
export type ShellExited = {
id: string
created: number
@@ -1286,6 +1321,7 @@ export type ModelCompatibility = {
reasoningField?: ModelReasoningField
maxTokensField?: ModelMaxTokensField
requireFinishReason?: boolean
requireAssistantAfterTool?: boolean
}
export type ModelCost = {
@@ -1381,6 +1417,24 @@ export type Project = {
sandboxes: Array<string>
}
export type ProjectUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.updated"
location?: LocationRef
data: {
id: string
canonical: string
vcs?: ProjectVcs
name?: string
icon?: ProjectIcon
commands?: ProjectCommands
time: ProjectTime
sandboxes: Array<string>
}
}
export type FormAnswer = { [x: string]: FormValue }
export type PermissionRequest = {
@@ -1391,6 +1445,7 @@ export type PermissionRequest = {
save?: Array<string>
metadata?: { [x: string]: JsonValue }
source?: PermissionSource
message?: string
}
export type PermissionAsked = {
@@ -1407,6 +1462,7 @@ export type PermissionAsked = {
save?: Array<string>
metadata?: { [x: string]: any }
source?: PermissionSource
message?: string
}
}
@@ -1437,6 +1493,22 @@ export type PtyUpdated = {
data: { info: Pty }
}
export type PersistentPtyAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "persistent-pty.added"
location?: LocationRef
data: { sessionID: string; terminal: PersistentPtyInfo }
}
export type PersistentPtySnapshot = {
info: PersistentPtyInfo
text: string
checkpoint: string
cursor: { x: number; y: number }
}
export type FormStringField1 = {
key: string
title?: string
@@ -2066,8 +2138,9 @@ export type IntegrationInfo = {
export type V2Event =
| ModelsDevRefreshed
| CredentialUpdated
| CredentialSwitched
| IntegrationUpdated
| IntegrationConnectionUpdated
| CatalogUpdated
| AgentUpdated
| SessionCreated
@@ -2122,6 +2195,7 @@ export type V2Event =
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
| WorktreeResolved
| CommandUpdated
@@ -2131,6 +2205,8 @@ export type V2Event =
| PtyUpdated
| PtyExited
| PtyDeleted
| PersistentPtyAdded
| PersistentPtyRemoved
| ShellCreated
| ShellExited
| ShellDeleted
@@ -2277,6 +2353,14 @@ export type McpServerNotFoundError = {
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
export type ProjectNotFoundError = {
readonly _tag: "ProjectNotFoundError"
readonly projectID: string
readonly message: string
}
export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError"
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
@@ -4297,6 +4381,15 @@ export type CredentialUpdateInput = {
export type CredentialUpdateOutput = void
export type CredentialActivateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CredentialActivateOutput = void
export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
@@ -4308,6 +4401,27 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = Array<Project>
export type ProjectUpdateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly name?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["name"]
readonly icon?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["icon"]
readonly commands?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["commands"]
}
export type ProjectUpdateOutput = Project
export type ProjectCurrentInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -5469,6 +5583,99 @@ export type PtyConnectTokenOutput = {
data: PtyTicketConnectToken
}
export type ExperimentalPersistentPtyListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
export type ExperimentalPersistentPtyCreateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
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 ExperimentalPersistentPtyCreateOutput = { data: PersistentPtyInfo }["data"]
export type ExperimentalPersistentPtyShutdownOutput = void
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ExperimentalPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
export type ExperimentalPersistentPtyUpdateInput = {
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 ExperimentalPersistentPtyUpdateOutput = { data: PersistentPtyInfo }["data"]
export type ExperimentalPersistentPtySnapshotInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ExperimentalPersistentPtySnapshotOutput = { data: PersistentPtySnapshot }["data"]
export type ExperimentalPersistentPtyRemoveInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ExperimentalPersistentPtyRemoveOutput = void
export type ExperimentalPersistentPtyConnectTokenInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly "x-opencode-ticket"?: { readonly "x-opencode-ticket"?: string | undefined }["x-opencode-ticket"]
}
export type ExperimentalPersistentPtyConnectTokenOutput = { data: PtyTicketConnectToken }["data"]
export type ShellListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -5593,24 +5800,35 @@ export type WorktreeCreateInput = {
readonly strategy: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["strategy"]
readonly from?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["from"]
readonly branch?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["branch"]
readonly directory: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["directory"]
readonly name?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["name"]
@@ -5663,6 +5881,29 @@ export type VcsStatusOutput = {
data: Array<VcsFileStatus>
}
export type VcsBranchesInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}["location"]
readonly search?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}["search"]
readonly limit?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}["limit"]
}
export type VcsBranchesOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: VcsBranchList
}
export type VcsDiffInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+33
View File
@@ -521,6 +521,9 @@ export function createData(config: CreateDataInput) {
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
case "project.updated":
setStore("project", "info", event.data.id, reconcile(event.data))
return
case "session.created":
sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
@@ -1045,6 +1048,36 @@ export function createData(config: CreateDataInput) {
return
}
if (event.type === "credential.updated" || event.type === "credential.switched") {
Object.keys(store.location).forEach((key) => {
const ref = JSON.parse(key) as [string, string | null]
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
if (event.type === "credential.updated") {
result.location.integration.invalidate(location)
void result.location.integration.sync(location)
return
}
setStore("location", key, (data) => ({
...data,
integration: data?.integration?.map((integration) => {
if (integration.id !== event.data.integrationID) return integration
const active = integration.connections.find(
(connection) => connection.type === "credential" && connection.id === event.data.credentialID,
)
if (!active) return integration
return {
...integration,
connections: [active, ...integration.connections.filter((connection) => connection !== active)],
}
}),
}))
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
})
return
}
if (!event.location) return
const location = event.location
switch (event.type) {
+44 -3
View File
@@ -26,8 +26,8 @@ test("exposes every standard HTTP API group", () => {
"skill",
"event",
"pty",
"experimental",
"shell",
"question",
"reference",
"worktree",
"workspace",
@@ -47,11 +47,11 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
@@ -82,6 +82,29 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
id: "proj_test",
canonical: "/tmp/project",
commands: { start: "bun install" },
time: { created: 1, updated: 2 },
sandboxes: [],
}
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json(project)
},
})
expect(await client.project.update({ projectID: "proj_test", commands: { start: "bun install" } })).toEqual(project)
expect(request?.method).toBe("PATCH")
expect(request?.url).toBe("http://localhost:3000/api/project/proj_test")
expect(await request?.json()).toEqual({ commands: { start: "bun install" } })
})
test("generate.text uses the locationless public contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
@@ -165,6 +188,24 @@ test("experimental wellknown integration add uses the public HTTP contract", asy
expect(await request?.json()).toEqual({ url: "https://example.com" })
})
test("credential.activate uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
await client.credential.activate({ credentialID: "cred_work", location: { directory: "/tmp/project" } })
expect(request?.method).toBe("POST")
expect(request?.url).toBe(
"http://localhost:3000/api/credential/cred_work/activate?location%5Bdirectory%5D=%2Ftmp%2Fproject",
)
})
test("integration connections optionally submit a form answer", async () => {
const requests: Request[] = []
const client = OpenCode.make({
+180 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
const session = (viewed: number): SessionInfo => ({
id: "ses_refresh",
@@ -72,6 +72,185 @@ test("revalidates after an event overtakes an active session read", async () =>
}
})
test("updates authoritative cached project metadata from live events", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const original: Project = {
id: "project_renamed",
canonical: "/projects/original",
name: "Original custom name",
time: { created: 1, updated: 1 },
sandboxes: [],
}
const unrelated: Project = {
id: "project_unrelated",
canonical: "/projects/unrelated",
name: "Unrelated project",
time: { created: 1, updated: 1 },
sandboxes: [],
}
let requests = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/api/project")) throw new Error(`Unexpected request: ${request.url}`)
requests++
return Response.json([original, unrelated])
},
})
const event: CreateDataInput["event"] = {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/projects/original", event }),
dispose,
}))
try {
await setup.data.project.sync()
expect(setup.data.project.get(original.id)).toEqual(original)
const updated: OpenCodeEvent = {
id: "evt_project_renamed",
created: 2,
type: "project.updated",
data: {
...original,
canonical: "/projects/renamed",
name: "Updated custom name",
time: { ...original.time, updated: 2 },
},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed")
expect(setup.data.project.get(original.id)?.name).toBe("Updated custom name")
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
expect(requests).toBe(1)
const reset: OpenCodeEvent = {
id: "evt_project_name_reset",
created: 3,
type: "project.updated",
data: {
id: original.id,
canonical: "/projects/renamed-again",
time: { ...original.time, updated: 3 },
sandboxes: [],
},
}
listeners.forEach((listener) => listener({ name: reset.type, details: reset }))
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed-again")
expect(setup.data.project.get(original.id)?.name).toBeUndefined()
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
expect(requests).toBe(1)
} finally {
setup.dispose()
}
})
test("refreshes global credential events across every loaded location and workspace", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
const directory = url.searchParams.get("location[directory]") ?? "/project"
return Response.json({
location: {
directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "project", directory, canonical: directory },
},
data: [],
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
const locations = [{ directory: "/project" }, { directory: "/other", workspaceID: "workspace-other" }]
try {
await Promise.all(
locations.flatMap((location) => [
setup.data.location.integration.sync(location),
setup.data.location.model.sync(location),
setup.data.location.provider.sync(location),
]),
)
requests.length = 0
const updated: OpenCodeEvent = {
id: "evt_credential.updated",
created: 1,
type: "credential.updated",
data: {},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
await wait(() => requests.length === 2)
expect(
requests.map((url) => [
url.pathname,
url.searchParams.get("location[directory]"),
url.searchParams.get("location[workspace]"),
]),
).toEqual([
["/api/integration", "/project", null],
["/api/integration", "/other", "workspace-other"],
])
requests.length = 0
for (const credentialID of ["credential", null]) {
const switched: OpenCodeEvent = {
id: `evt_credential.switched.${credentialID}`,
created: 2,
type: "credential.switched",
data: { credentialID, integrationID: "integration" },
}
listeners.forEach((listener) => listener({ name: switched.type, details: switched }))
await wait(() => requests.length === 4)
expect(
requests.map((url) => [
url.pathname,
url.searchParams.get("location[directory]"),
url.searchParams.get("location[workspace]"),
]),
).toEqual(
expect.arrayContaining([
["/api/model", "/project", null],
["/api/provider", "/project", null],
["/api/model", "/other", "workspace-other"],
["/api/provider", "/other", "workspace-other"],
]),
)
requests.length = 0
}
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
+6
View File
@@ -41,6 +41,12 @@
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
},
"#persistent-pty-binary": {
"workerd": "./src/persistent-pty/binary.workerd.ts",
"bun": "./src/persistent-pty/binary.bun.ts",
"node": "./src/persistent-pty/binary.node.ts",
"default": "./src/persistent-pty/binary.bun.ts"
},
"#fff": {
"workerd": "./src/filesystem/fff.workerd.ts",
"bun": "./src/filesystem/fff.bun.ts",
+11 -1
View File
@@ -34,6 +34,7 @@ import {
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { makeParser } from "effect/unstable/encoding/Sse"
import type { ID, Info } from "./model.js"
import { Provider } from "./provider.js"
import { State } from "./state.js"
@@ -65,15 +66,23 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (!res.headers.get("content-type")?.includes("text/event-stream")) return res
const reader = res.body.getReader()
const decoder = new TextDecoder()
let deadline: number | undefined
const parser = makeParser((event) => {
if (event._tag === "Event") deadline = Date.now() + ms
})
const body = new ReadableStream<Uint8Array>({
async pull(ctrl) {
const expires = deadline ?? Date.now() + ms
deadline = expires
const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
const remaining = Math.max(0, expires - Date.now())
const id = setTimeout(() => {
const err = new Error("SSE read timed out")
ctl.abort(err)
void reader.cancel(err)
reject(err)
}, ms)
}, remaining)
reader.read().then(
(part) => {
@@ -92,6 +101,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
return
}
parser.feed(decoder.decode(part.value, { stream: true }))
ctrl.enqueue(part.value)
},
async cancel(reason) {
+13 -10
View File
@@ -89,6 +89,7 @@ export interface PublishOptions {
readonly id?: Event.ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
readonly global?: boolean
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void>
}
@@ -446,11 +447,12 @@ export function configured(options?: Options) {
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
const location = options?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
return yield* publishEvent(
definition,
{
@@ -480,11 +482,12 @@ export function configured(options?: Options) {
}),
)
}
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
const location = options?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
return {
definition,
aggregateID,
+2 -5
View File
@@ -16,7 +16,6 @@ import {
type Entry,
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"
@@ -159,9 +158,7 @@ export const layer = (options?: Options) =>
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",
)
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
@@ -343,7 +340,7 @@ export const layer = (options?: Options) =>
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
+105 -8
View File
@@ -1,10 +1,11 @@
export * as Credential from "./credential.js"
import { asc, eq } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database.js"
import { Bus } from "./bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql.js"
@@ -20,6 +21,8 @@ export type Key = Credential.Key
export const Value = Credential.Value
export type Value = Credential.Value
export const Event = Credential.Event
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
integrationID: Integration.ID,
@@ -34,12 +37,14 @@ export interface Interface {
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */
/** Creates a credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: Integration.ID
readonly value: Value
readonly label?: string
}) => Effect.Effect<Info>
/** Selects a stored credential for its integration. */
readonly activate: (id: ID) => Effect.Effect<void>
/** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */
@@ -52,6 +57,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
@@ -73,7 +79,7 @@ const layer = Layer.effect(
db
.select()
.from(CredentialTable)
.orderBy(asc(CredentialTable.time_created))
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
@@ -82,7 +88,7 @@ const layer = Layer.effect(
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, integrationID))
.orderBy(asc(CredentialTable.time_created))
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
@@ -101,7 +107,8 @@ const layer = Layer.effect(
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.delete(CredentialTable)
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integrationID))
.run()
yield* tx
@@ -111,27 +118,117 @@ const layer = Layer.effect(
integration_id: credential.integrationID,
label: credential.label,
value: credential.value,
active: true,
})
.run()
}),
)
.pipe(Effect.orDie)
yield* bus.publish(Event.Updated, {}, { global: true })
yield* bus.publish(
Event.Switched,
{ integrationID: credential.integrationID, credentialID: credential.id },
{ global: true },
)
return credential
}),
activate: Effect.fn("Credential.activate")(function* (id) {
const integrationID = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential?.integration_id) return
const active = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
if (active?.id === id) return
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integration_id))
.run()
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run()
return credential.integration_id
}),
)
.pipe(Effect.orDie)
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
}),
update: Effect.fn("Credential.update")(function* (id, updates) {
if (!updates.label && !updates.value) return
if (updates.label === undefined && updates.value === undefined) return
const credential = yield* db
.select({ integrationID: CredentialTable.integration_id, label: CredentialTable.label })
.from(CredentialTable)
.where(eq(CredentialTable.id, id))
.get()
.pipe(Effect.orDie)
if (!credential?.integrationID) return
if (updates.label === credential.label && updates.value === undefined) return
yield* db
.update(CredentialTable)
.set({ label: updates.label, value: updates.value })
.where(eq(CredentialTable.id, id))
.run()
.pipe(Effect.orDie)
if (updates.label !== undefined && updates.label !== credential.label)
yield* bus.publish(Event.Updated, {}, { global: true })
}),
remove: Effect.fn("Credential.remove")(function* (id) {
yield* db.delete(CredentialTable).where(eq(CredentialTable.id, id)).run().pipe(Effect.orDie)
const removed = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential) return
const active = credential.integration_id
? yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
: undefined
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
const replacement = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
if (replacement) {
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integration_id))
.run()
yield* tx
.update(CredentialTable)
.set({ active: true })
.where(eq(CredentialTable.id, replacement.id))
.run()
}
return {
switched: true as const,
integrationID: credential.integration_id,
credentialID: replacement?.id ?? null,
}
}),
)
.pipe(Effect.orDie)
if (!removed) return
yield* bus.publish(Event.Updated, {}, { global: true })
if (removed.switched)
yield* bus.publish(
Event.Switched,
{ integrationID: removed.integrationID, credentialID: removed.credentialID },
{ global: true },
)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
+33 -6
View File
@@ -66,12 +66,39 @@ export function applyOnly(db: Database, input: Migration[]) {
if (
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
const named = (yield* db.all<{ name: string }>(
sql`SELECT name FROM pragma_table_info('__drizzle_migrations')`,
)).some((column) => column.name === "name")
if (named) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
}
if (!named) {
const entries = yield* db.all<{ created_at: number; prefix: string | null }>(sql`
SELECT created_at, strftime('%Y%m%d%H%M%S', created_at / 1000, 'unixepoch') AS prefix
FROM ${sql.identifier("__drizzle_migrations")}
WHERE created_at IS NOT NULL
`)
for (const entry of entries) {
const migration = input.find((item) => item.id.startsWith(`${entry.prefix}_`))
if (!migration) {
return yield* Effect.die(
new Error(`Legacy migration timestamp ${entry.created_at} does not match any known migration`),
)
}
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
VALUES (${migration.id}, ${Date.now()})
`)
}
}
completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
@@ -5,6 +5,9 @@ const migration: DatabaseMigration.Migration = {
id: "20260410174513_workspace-name",
up(tx) {
return Effect.gen(function* () {
const columns = yield* tx.all<{ name: string }>(`PRAGMA table_info(\`workspace\`)`)
const name = columns.some((column) => column.name === "name") ? "`name`" : "''"
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_workspace\` (
@@ -19,7 +22,7 @@ const migration: DatabaseMigration.Migration = {
);
`)
yield* tx.run(
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, ${name}, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
)
yield* tx.run(`DROP TABLE \`workspace\`;`)
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
+3 -1
View File
@@ -109,6 +109,7 @@ export interface Interface {
readonly create: (input: {
repository: Repository
directory: AbsolutePath
ref?: string
}) => Effect.Effect<Repository, WorktreeError>
readonly remove: (input: {
repository: Repository
@@ -644,11 +645,12 @@ const layer = Layer.effect(
const worktreeCreate = Effect.fn("Git.worktree.create")(function* (input: {
repository: Repository
directory: AbsolutePath
ref?: string
}) {
yield* worktreeRun(
"create",
input.repository,
["worktree", "add", "--detach", input.directory, "HEAD"],
["worktree", "add", "--detach", "--", input.directory, input.ref ?? "HEAD"],
input.directory,
)
const repository = yield* discover(input.directory)
+32 -30
View File
@@ -166,6 +166,8 @@ export interface Interface extends State.Transformable<Draft> {
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
/** Selects a stored credential as the active integration connection. */
readonly activate: (credentialID: Credential.ID) => Effect.Effect<void>
/** Updates a stored credential exposed as a connection. */
readonly update: (
credentialID: Credential.ID,
@@ -329,6 +331,17 @@ const layer = Layer.effect(
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
if (input.label !== undefined) return yield* credentials.create(input)
const name = state.get().integrations.get(input.integrationID)?.ref.name ?? input.integrationID
const labels = new Set((yield* credentials.list(input.integrationID)).map((credential) => credential.label))
const label =
Array.from({ length: labels.size + 1 }, (_, index) => (index === 0 ? name : `${name} ${index + 1}`)).find(
(candidate) => !labels.has(candidate),
) ?? name
return yield* credentials.create({ ...input, label })
})
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const credentials = saved
.map((credential) => ({
@@ -394,7 +407,7 @@ const layer = Layer.effect(
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
credentials.create({
createCredential({
integrationID: attempt.integrationID,
label,
value: exit.value,
@@ -421,8 +434,6 @@ const layer = Layer.effect(
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
)
@@ -452,13 +463,11 @@ const layer = Layer.effect(
return
}
const persistence = yield* credentials
.create({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
})
.pipe(Effect.asVoid, Effect.exit)
const persistence = yield* createCredential({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
@@ -476,9 +485,6 @@ const layer = Layer.effect(
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
if (Exit.isFailure(persistence)) return
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}),
)
})
@@ -702,7 +708,7 @@ const layer = Layer.effect(
if (!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({
yield* createCredential({
integrationID: input.integrationID,
label: input.label,
value: Credential.Key.make({
@@ -711,25 +717,21 @@ const layer = Layer.effect(
...(Object.keys(answer).length > 0 ? { configuration: answer } : {}),
}),
})
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}),
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
activate: Effect.fn("Integration.connection.activate")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.update(credentialID, updates)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.remove(credentialID)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
if (!credential) return
const active = resolveConnections(
state.get().integrations.get(credential.integrationID),
yield* credentials.list(credential.integrationID),
)[0]
if (active?.type === "credential" && active.id === credentialID) return
yield* credentials.activate(credentialID)
}),
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
credentials.update(credentialID, updates),
),
remove: Effect.fn("Integration.connection.remove")((credentialID) => credentials.remove(credentialID)),
},
oauth: {
connect: connectOAuth,
+6 -10
View File
@@ -140,8 +140,7 @@ export type Draft = {
remove: (server: ServerName | string) => void
}
const cloneConfig = (config: Mcp.ServerConfig) =>
structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
export interface Interface extends State.Transformable<Draft> {
readonly servers: () => Effect.Effect<ServerInfo[]>
@@ -264,8 +263,7 @@ export const layer = (options?: Options) =>
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
onRedirect: () => {},
}
const stored = yield* credentials.list(entry.integrationID)
const found = stored.find((credential) => credential.value.type === "oauth")
const found = (yield* credentials.list(entry.integrationID)).at(-1)
if (!found || found.value.type !== "oauth")
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
@@ -288,8 +286,8 @@ export const layer = (options?: Options) =>
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
// connection may have already replaced ours; deleting then would discard the newer valid credential and
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
// reconnects remain serialized by the server lock.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") return
const oauth = await readOAuthCredential()
@@ -673,7 +671,7 @@ export const layer = (options?: Options) =>
}).pipe(locks.withLock(name))
})
fork(
bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => owned.has(event.data.integrationID)),
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
Effect.ignore,
@@ -687,9 +685,7 @@ export const layer = (options?: Options) =>
config === false ? [] : [[name, cloneConfig(config)] as const],
),
),
removed: new Set(
Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : [])),
),
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
}),
draft: (draft) => ({
list: () => Array.from(draft.servers),
+49 -56
View File
@@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema.js"
import { SessionStore } from "./session/store.js"
import { Wildcard } from "./util/wildcard.js"
import { PermissionSaved } from "./permission/saved.js"
import { PluginHooks } from "./plugin/hooks.js"
const PermissionEffect = Permission.Effect
export { PermissionEffect as Effect }
@@ -70,9 +71,10 @@ export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission
rules: Permission.Ruleset,
permission: Schema.String,
resources: Schema.Array(Schema.String),
reason: Schema.String.pipe(Schema.optional),
}) {
override get message() {
return `Permission denied: ${this.permission}`
return this.reason ?? `Permission denied: ${this.permission}`
}
}
@@ -99,11 +101,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly allowsAll: (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) => Effect.Effect<boolean, SessionErrors.NotFoundError>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -128,6 +125,7 @@ const layer = Layer.effect(
const agents = yield* Agent.Service
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
@@ -159,24 +157,6 @@ const layer = Layer.effect(
return agent?.permissions ?? missingAgentPermissions
})
const allowsAll = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) {
const rules = yield* configured(input.sessionID, input.agent)
const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action))
for (let index = relevant.length - 1; index >= 0; index--) {
const rule = relevant[index]
if (rule.resource !== "*") {
if (rule.effect !== "allow") return false
continue
}
return rule.effect === "allow"
}
return false
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
@@ -191,10 +171,19 @@ const layer = Layer.effect(
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: input.sessionID,
agent: input.agent,
action: input.action,
resources: input.resources,
metadata: input.metadata,
source: input.source,
effect,
})
return { effect: event.effect, message: event.message, rules: all }
})
function request(input: AssertInput): Request {
function request(input: AssertInput, message?: string): Request {
return {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
@@ -203,6 +192,7 @@ const layer = Layer.effect(
save: input.save,
metadata: input.metadata,
source: input.source,
message,
}
}
@@ -223,39 +213,42 @@ const layer = Layer.effect(
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input)
const value = request(input, result.message)
if (result.effect === "ask") yield* create(value, input.agent)
return { id: value.id, effect: result.effect }
})
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
),
Effect.gen(function* () {
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
reason: result.message,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input, result.message), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
)
}),
)
const reply = Effect.fn("Permission.reply")((input: ReplyInput) =>
@@ -337,12 +330,12 @@ const layer = Layer.effect(
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ allowsAll, ask, assert, reply, get, forSession, list })
return Service.of({ ask, assert, reply, get, forSession, list })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node],
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node],
})
+1
View File
@@ -0,0 +1 @@
export { PersistentPty } from "./persistent-pty/index.js"
@@ -0,0 +1,82 @@
import { createHash } from "node:crypto"
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"
import path from "node:path"
import asset from "./pty-binding.js"
export async function resolveBinary(bin: string) {
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
if (!asset) return "opencode-pty"
return install(bin, asset)
}
async function install(
bin: string,
input: { readonly path: string; readonly version: string; readonly sha256: string },
) {
const root = path.join(bin, "opencode-pty")
await privateDirectory(root)
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
await privateDirectory(directory)
const destination = path.join(directory, "opencode-pty")
if (await exists(destination, input.sha256)) return destination
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
const temporary = path.join(directory, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
try {
const file = await open(temporary, "wx", 0o700)
try {
await file.writeFile(bytes)
await file.sync()
} finally {
await file.close()
}
await chmod(temporary, 0o755)
await rename(temporary, destination).catch(async (error) => {
if (!(await exists(destination, input.sha256))) throw error
})
} finally {
await rm(temporary, { force: true })
}
return validate(destination, input.sha256)
}
async function privateDirectory(directory: string) {
await mkdir(directory, { recursive: true, mode: 0o700 })
const info = await lstat(directory)
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty directory: ${directory}`)
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
if (uid !== undefined && info.uid !== uid)
throw new Error(`opencode-pty directory is owned by another user: ${directory}`)
await chmod(directory, 0o700)
}
async function exists(file: string, expected: string) {
try {
await validate(file, expected)
return true
} catch (error) {
if (isMissing(error)) return false
throw error
}
}
async function validate(file: string, expected?: string) {
const info = await lstat(file)
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
if (uid !== undefined && info.uid !== uid)
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
if (expected && sha256(await readFile(file)) !== expected)
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
await chmod(file, 0o755)
return file
}
function sha256(bytes: Uint8Array) {
return createHash("sha256").update(bytes).digest("hex")
}
function isMissing(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === "ENOENT"
}
@@ -0,0 +1,3 @@
export async function resolveBinary() {
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
}
@@ -0,0 +1,3 @@
export async function resolveBinary(): Promise<string> {
throw new Error("Persistent PTYs are unavailable in this runtime")
}
+535
View File
@@ -0,0 +1,535 @@
import { spawn } from "node:child_process"
import { readFile } from "node:fs/promises"
import net from "node:net"
import path from "node:path"
import { Data, Duration, Effect, Schema, Semaphore } from "effect"
const ProtocolVersion = 6
const MaxFrameBytes = 8 * 1024 * 1024
const Lifecycle = Schema.Union([
Schema.Struct({ status: Schema.Literal("running") }),
Schema.Struct({ status: Schema.Literal("exited"), exit_code: Schema.NullOr(Schema.Number) }),
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String }),
])
export const WireTerminal = Schema.Struct({
id: Schema.Number,
pid: Schema.NullOr(Schema.Number),
title: Schema.String,
foreground_process: Schema.NullOr(Schema.String),
group_id: Schema.String,
command: Schema.Array(Schema.String),
cwd: Schema.String,
cols: Schema.Number,
rows: Schema.Number,
lifecycle: Lifecycle,
output_head: Schema.Number,
output_tail: Schema.Number,
})
export type WireTerminal = typeof WireTerminal.Type
const Registration = Schema.Struct({
instance_id: Schema.String,
pid: Schema.Number,
protocol: Schema.Number,
socket: Schema.String,
token: Schema.String,
})
type Registration = typeof Registration.Type
export const WireResponse = Schema.Union([
Schema.Struct({
type: Schema.Literal("pong"),
instance_id: Schema.String,
pid: Schema.Number,
protocol: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
Schema.Struct({ type: Schema.Literal("ok") }),
Schema.Struct({
type: Schema.Literal("snapshot"),
terminal: WireTerminal,
text: Schema.String,
checkpoint_base64: Schema.String,
cursor_x: Schema.Number,
cursor_y: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("attached"),
terminal: WireTerminal,
role: Schema.Literals(["controller", "observer"]),
generation: Schema.Number,
requested_offset: Schema.Number,
available_offset: Schema.Number,
end_offset: Schema.Number,
truncated: Schema.Boolean,
replay_base64: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("resized"),
cols: Schema.Number,
rows: Schema.Number,
generation: Schema.Number,
checkpoint_base64: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("exited"),
exit_code: Schema.NullOr(Schema.Number),
final_offset: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("controller_changed"),
attachment_id: Schema.NullOr(Schema.String),
generation: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("title_changed"), title: Schema.String }),
Schema.Struct({ type: Schema.Literal("foreground_process_changed"), process: Schema.NullOr(Schema.String) }),
Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }),
])
export type WireResponse = typeof WireResponse.Type
export type Role = "controller" | "observer"
export type StreamEvent =
| { readonly type: "output"; readonly start: number; readonly end: number; readonly data: Uint8Array }
| {
readonly type: "resized"
readonly cols: number
readonly rows: number
readonly generation: number
readonly checkpoint: Uint8Array
}
| { readonly type: "exited"; readonly exitCode?: number; readonly finalOffset: number }
| { readonly type: "controller_changed"; readonly attachmentID?: string; readonly generation: number }
| { readonly type: "title_changed"; readonly title: string }
| { readonly type: "foreground_process_changed"; readonly process: string | null }
export type DaemonAttachment = {
readonly terminal: WireTerminal
readonly role: Role
readonly generation: number
readonly replay: {
readonly requestedOffset: number
readonly availableOffset: number
readonly endOffset: number
readonly truncated: boolean
readonly data: Uint8Array
}
readonly activate: () => void
readonly detach: () => void
}
export class DaemonError extends Data.TaggedError("PersistentPty.DaemonError")<{
readonly kind: "connect" | "response" | "registration" | "protocol" | "spawn"
readonly message: string
readonly pid?: number
}> {}
export interface DaemonTransport {
readonly request: (value: object, start?: boolean) => Effect.Effect<WireResponse, DaemonError>
readonly requestIfRunning: (value: object) => Effect.Effect<WireResponse | undefined, DaemonError>
readonly shutdown: Effect.Effect<WireResponse | undefined, DaemonError>
readonly subscribe: (
id: number,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) => Effect.Effect<DaemonAttachment, DaemonError>
}
export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport")(function* (
directory: string,
binary: () => Promise<string> = () => Promise.resolve(process.env.OPENCODE_PTY_BIN || "opencode-pty"),
) {
const startup = Semaphore.makeUnsafe(1)
let registration: Registration | undefined
const discover = Effect.fn("PersistentPty.daemon.discover")(function* () {
const value = yield* Effect.tryPromise({
try: () => readFile(path.join(directory, "service.json"), "utf8"),
catch: (cause) => failure("connect", cause),
})
const decoded = yield* Effect.try({
try: () => Schema.decodeUnknownSync(Registration)(JSON.parse(value)),
catch: (cause) => failure("protocol", cause),
})
if (decoded.protocol !== ProtocolVersion)
return yield* Effect.fail(
new DaemonError({
kind: "protocol",
message: `opencode-pty protocol mismatch: daemon=${decoded.protocol}, client=${ProtocolVersion}`,
pid: decoded.pid,
}),
)
const response = yield* oneShot(decoded, { op: "ping" })
if (
response.type !== "pong" ||
response.instance_id !== decoded.instance_id ||
response.pid !== decoded.pid ||
response.protocol !== ProtocolVersion
)
return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "opencode-pty registration mismatch" }))
return decoded
})
const start = Effect.fn("PersistentPty.daemon.start")(function* () {
const executable = yield* Effect.tryPromise({ try: binary, catch: (cause) => failure("spawn", cause) })
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn(executable, ["daemon"], {
detached: true,
stdio: "ignore",
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: directory },
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
}),
catch: (cause) => failure("spawn", cause),
})
const deadline = Date.now() + 5_000
let last: DaemonError | undefined
while (Date.now() < deadline) {
const found = yield* discover().pipe(
Effect.map((value) => ({ value })),
Effect.catch((error) => {
last = error
return Effect.succeed(undefined)
}),
)
if (found) return found.value
yield* Effect.sleep(50)
}
return yield* Effect.fail(
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
)
})
const connect = Effect.fn("PersistentPty.daemon.connect")(function* (shouldStart: boolean) {
if (registration) return registration
return yield* startup.withPermit(
Effect.gen(function* () {
if (registration) return registration
const found = yield* discover().pipe(
Effect.catch((error) => {
if (!shouldStart) return Effect.fail(error)
if (error.kind === "connect") return start()
if (error.kind !== "protocol" || error.pid === undefined) return Effect.fail(error)
return terminate(error.pid).pipe(Effect.andThen(start()))
}),
)
registration = found
return found
}),
)
})
const attempt = Effect.fn("PersistentPty.daemon.request-attempt")(function* (value: object, shouldStart: boolean) {
const current = yield* connect(shouldStart)
return yield* oneShot(current, value).pipe(
Effect.catch((error) => {
if (error.kind === "registration" && registration === current) registration = undefined
return Effect.fail(error)
}),
)
})
const request = Effect.fn("PersistentPty.daemon.request")(function* (value: object, shouldStart = false) {
return yield* attempt(value, shouldStart).pipe(
Effect.catch((error) => {
if (error.kind === "registration") return attempt(value, shouldStart)
if (error.kind !== "connect") return Effect.fail(error)
registration = undefined
if (!shouldStart) return Effect.fail(error)
return attempt(value, true)
}),
)
})
const requestIfRunning = (value: object) =>
request(value).pipe(
Effect.catch((error) => (error.kind === "connect" ? Effect.succeed(undefined) : Effect.fail(error))),
)
const shutdown = Effect.gen(function* () {
const response = yield* requestIfRunning({ op: "shutdown" })
registration = undefined
if (!response) return undefined
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
const running = yield* discover().pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!running) return response
yield* Effect.sleep(50)
}
return yield* Effect.fail(new DaemonError({ kind: "connect", message: "opencode-pty did not stop" }))
})
const subscribe = Effect.fn("PersistentPty.daemon.subscribe")(function* (
id: number,
input: Parameters<DaemonTransport["subscribe"]>[1],
) {
const attempt = Effect.gen(function* () {
const current = yield* connect(false)
return yield* Effect.tryPromise({
try: () => subscribePromise(current, id, input),
catch: (cause) => (cause instanceof DaemonError ? cause : failure("connect", cause)),
}).pipe(
Effect.catch((error) => {
if (error.kind === "registration" && registration === current) registration = undefined
return Effect.fail(error)
}),
)
})
return yield* attempt.pipe(Effect.catch((error) => (error.kind === "registration" ? attempt : Effect.fail(error))))
})
return { request, requestIfRunning, shutdown, subscribe } satisfies DaemonTransport
})
const oneShot = Effect.fn("PersistentPty.daemon.oneShot")(function* (registration: Registration, request: object) {
const payload = yield* Effect.try({
try: () => encode({ token: registration.token, request }),
catch: (cause) => failure("protocol", cause),
})
let dispatched = false
return yield* Effect.acquireUseRelease(
Effect.tryPromise({
try: (signal) =>
new Promise<net.Socket>((resolve, reject) => {
const socket = net.createConnection({ path: registration.socket, signal })
socket.once("connect", () => resolve(socket))
socket.once("error", reject)
}),
catch: (cause) => failure("connect", cause),
}),
(socket) =>
Effect.gen(function* () {
yield* Effect.try({
try: () => {
dispatched = true
socket.write(payload)
},
catch: (cause) => failure("response", cause),
})
const first = yield* Effect.tryPromise({
try: async (signal) => {
const frames = decoder(socket)
const abort = () => socket.destroy()
signal.addEventListener("abort", abort, { once: true })
try {
const frame = await frames.next()
if (frame.done) throw new Error("opencode-pty closed without response")
return frame.value
} finally {
signal.removeEventListener("abort", abort)
}
},
catch: (cause) => failure("response", cause),
})
const response = yield* Effect.try({
try: () => decode(first),
catch: (cause) => failure("protocol", cause),
})
if (response.type === "error")
return yield* Effect.fail(
new DaemonError({
kind: response.message === "authentication failed" ? "registration" : "protocol",
message: response.message,
}),
)
return response
}),
(socket) => Effect.sync(() => socket.destroy()),
).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(5),
orElse: () =>
Effect.fail(
new DaemonError({
kind: dispatched ? "response" : "connect",
message: "opencode-pty request timed out",
}),
),
}),
)
})
async function subscribePromise(
registration: Registration,
id: number,
input: Parameters<DaemonTransport["subscribe"]>[1],
): Promise<DaemonAttachment> {
const socket = net.createConnection(registration.socket)
const frames = decoder(socket)
try {
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve)
socket.once("error", reject)
})
socket.write(
encode({
token: registration.token,
request: {
op: "subscribe",
id,
offset: input.cursor,
attachment_id: input.attachmentID,
role: input.role,
takeover: input.takeover ?? false,
},
}),
)
const initial = await frames.next()
if (initial.done) throw new Error("opencode-pty closed before attachment")
const response = decode(initial.value)
if (response.type === "error")
throw new DaemonError({
kind: response.message === "authentication failed" ? "registration" : "protocol",
message: response.message,
})
if (response.type !== "attached") throw new Error(`unexpected opencode-pty response: ${response.type}`)
let detached = false
const pump = async () => {
try {
for await (const frame of frames) {
if (frame[0] === 0) {
if (frame.length < 17) throw new Error("invalid opencode-pty output frame")
input.onEvent({
type: "output",
start: Number(frame.readBigUInt64BE(1)),
end: Number(frame.readBigUInt64BE(9)),
data: frame.subarray(17),
})
continue
}
const event = decode(frame)
if (event.type === "resized")
input.onEvent({
type: "resized",
cols: event.cols,
rows: event.rows,
generation: event.generation,
checkpoint: Buffer.from(event.checkpoint_base64, "base64"),
})
if (event.type === "controller_changed")
input.onEvent({
type: "controller_changed",
attachmentID: event.attachment_id ?? undefined,
generation: event.generation,
})
if (event.type === "title_changed") input.onEvent({ type: "title_changed", title: event.title })
if (event.type === "foreground_process_changed")
input.onEvent({ type: "foreground_process_changed", process: event.process })
if (event.type === "exited") {
input.onEvent({
type: "exited",
exitCode: event.exit_code ?? undefined,
finalOffset: event.final_offset,
})
return
}
}
} finally {
if (!detached) input.onEnd()
}
}
let activated = false
return {
terminal: response.terminal,
role: response.role,
generation: response.generation,
replay: {
requestedOffset: response.requested_offset,
availableOffset: response.available_offset,
endOffset: response.end_offset,
truncated: response.truncated,
data: Buffer.from(response.replay_base64, "base64"),
},
activate() {
if (activated || detached) return
activated = true
void pump().catch(() => {})
},
detach() {
if (detached) return
detached = true
socket.destroy()
},
}
} catch (error) {
socket.destroy()
throw error
}
}
function encode(value: unknown) {
const payload = Buffer.from(JSON.stringify(value))
if (payload.length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
const output = Buffer.allocUnsafe(payload.length + 4)
output.writeUInt32BE(payload.length)
payload.copy(output, 4)
return output
}
async function* decoder(socket: net.Socket) {
let pending = Buffer.alloc(0)
for await (const value of socket) {
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk])
while (pending.length >= 4) {
const length = pending.readUInt32BE(0)
if (length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
if (pending.length < length + 4) break
yield pending.subarray(4, length + 4)
pending = pending.subarray(length + 4)
}
}
if (pending.length !== 0) throw new Error("opencode-pty truncated frame")
}
function decode(payload: Uint8Array) {
return Schema.decodeUnknownSync(WireResponse)(JSON.parse(Buffer.from(payload).toString("utf8")))
}
function failure(kind: DaemonError["kind"], cause: unknown) {
return new DaemonError({ kind, message: cause instanceof Error ? cause.message : String(cause) })
}
const terminate = Effect.fn("PersistentPty.daemon.terminate-incompatible")(function* (pid: number) {
yield* Effect.logWarning("replacing incompatible opencode-pty daemon", { pid })
yield* Effect.try({ try: () => process.kill(pid, "SIGTERM"), catch: (cause) => failure("spawn", cause) }).pipe(
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
)
const deadline = Date.now() + 2_000
while (Date.now() < deadline && processRunning(pid)) yield* Effect.sleep(25)
if (!processRunning(pid)) return
yield* Effect.try({ try: () => process.kill(pid, "SIGKILL"), catch: (cause) => failure("spawn", cause) }).pipe(
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
)
})
function processRunning(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function isMissingProcess(error: DaemonError) {
return error.message.includes("ESRCH") || error.message.includes("no such process")
}
+397
View File
@@ -0,0 +1,397 @@
export * as PersistentPty from "./index.js"
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Removed } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Pty } from "@opencode-ai/schema/pty"
import { Global } from "@opencode-ai/util/global"
import {
makeDaemonTransport,
type DaemonTransport,
type Role,
type StreamEvent,
type WireResponse,
type WireTerminal,
} from "./daemon.js"
import { resolveBinary } from "#persistent-pty-binary"
export type { Role, StreamEvent } from "./daemon.js"
export type Info = Pty.Info & {
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type Attachment = {
readonly info: Info
readonly role: Role
readonly generation: number
readonly replay: {
readonly requestedOffset: number
readonly availableOffset: number
readonly endOffset: number
readonly truncated: boolean
readonly data: Uint8Array
}
readonly activate: () => void
readonly detach: () => void
}
export class UnavailableError extends Schema.TaggedError<UnavailableError>()("PersistentPty.UnavailableError", {
message: Schema.String,
}) {}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("PersistentPty.NotFoundError", {
ptyID: Pty.ID,
}) {}
export interface Interface {
readonly list: (sessionID?: Session.ID) => Effect.Effect<Info[], UnavailableError>
readonly get: (id: Pty.ID) => Effect.Effect<Info, NotFoundError | UnavailableError>
readonly create: (
sessionID: Session.ID,
input: {
readonly command: string
readonly args: readonly string[]
readonly cwd: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) => Effect.Effect<Info, UnavailableError>
readonly write: (
id: Pty.ID,
data: string,
attachmentID?: string,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly resize: (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly control: (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly input: (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly shutdown: () => Effect.Effect<void, UnavailableError>
readonly attach: (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) => Effect.Effect<Attachment, NotFoundError | UnavailableError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const database = yield* Database.Service
const global = yield* Global.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
let binary: Promise<string> | undefined
const daemon = yield* makeDaemonTransport(
runtimeDirectory(databasePath(database.db)),
() =>
(binary ??= resolveBinary(global.bin).catch((error) => {
binary = undefined
throw error
})),
)
const removing = new Set<Pty.ID>()
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
const response = yield* optionalRequest(daemon, { op: "list" })
if (!response) return []
if (response.type !== "terminals") return yield* unexpected(response)
return response.terminals
.map(toInfo)
.filter((terminal) => sessionID === undefined || terminal.sessionID === sessionID)
})
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
const found = (yield* list()).find((terminal) => terminal.id === id)
if (!found) return yield* new NotFoundError({ ptyID: id })
return found
})
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: {
readonly command: string
readonly args: readonly string[]
readonly cwd: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) {
const response = yield* request(
daemon,
{
op: "create",
program: input.command,
args: input.args,
cwd: input.cwd,
title: input.title,
group_id: sessionID,
env: input.env,
cols: input.cols ?? 80,
rows: input.rows ?? 24,
},
true,
)
if (response.type !== "created") return yield* unexpected(response)
const terminal = toInfo(response.terminal)
yield* bus.publish(Added, { sessionID, terminal })
return terminal
})
const write = Effect.fn("PersistentPty.write")(function* (id: Pty.ID, data: string, attachmentID?: string) {
yield* get(id)
const response = yield* request(daemon, {
op: "write",
id: fromID(id),
attachment_id: attachmentID ?? null,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const resize = Effect.fn("PersistentPty.resize")(function* (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "resize",
id: fromID(id),
attachment_id: attachmentID ?? null,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const control = Effect.fn("PersistentPty.control")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "control",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const input = Effect.fn("PersistentPty.input")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "input",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
yield* get(id)
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
if (response.type !== "snapshot") return yield* unexpected(response)
return {
info: toInfo(response.terminal),
text: response.text,
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
cursor: { x: response.cursor_x, y: response.cursor_y },
}
})
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
const terminal = yield* get(id)
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
if (response.type !== "ok") return yield* unexpected(response)
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
return undefined
})
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
if (!response) return
if (response.type !== "ok") return yield* unexpected(response)
})
const removeVisibleExit = (id: Pty.ID) => {
if (removing.has(id)) return
removing.add(id)
runFork(
remove(id).pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": () => Effect.void,
"PersistentPty.UnavailableError": (error) =>
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
}),
Effect.ensuring(Effect.sync(() => removing.delete(id))),
),
)
}
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) {
yield* get(id)
const attachment = yield* daemon
.subscribe(fromID(id), {
...input,
onEvent: (event) => {
if (event.type === "exited") removeVisibleExit(id)
input.onEvent(event)
},
})
.pipe(Effect.mapError(unavailable))
return {
info: toInfo(attachment.terminal),
role: attachment.role,
generation: attachment.generation,
replay: attachment.replay,
activate: attachment.activate,
detach: attachment.detach,
}
})
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node, Global.node] })
const request = (daemon: DaemonTransport, value: object, start = false) =>
daemon.request(value, start).pipe(Effect.mapError(unavailable))
const optionalRequest = (daemon: DaemonTransport, value: object) =>
daemon.requestIfRunning(value).pipe(Effect.mapError(unavailable))
const unexpected = (response: WireResponse) =>
Effect.fail(new UnavailableError({ message: `unexpected opencode-pty response: ${response.type}` }))
const unavailable = (error: unknown) =>
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
function databasePath(db: Database.Interface["db"]) {
const client: unknown = db.$client
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
return undefined
const config = client.config
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
return path.resolve(config.filename)
}
const runtimeDirectory = (databasePath?: string) => {
const root =
process.env.OPENCODE_PTY_RUNTIME_DIR ??
(process.env.XDG_RUNTIME_DIR
? path.join(process.env.XDG_RUNTIME_DIR, "opencode-pty")
: path.join(
os.tmpdir(),
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
))
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
}
function toInfo(value: WireTerminal): Info {
const status = value.lifecycle.status
return {
...Pty.Info.make({
id: toID(value.id),
title: value.title,
command: value.command[0] || "",
args: value.command.slice(1),
cwd: value.cwd,
status: status === "running" ? "running" : "exited",
pid: value.pid ?? 0,
...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}),
}),
sessionID: Session.ID.make(value.group_id),
foregroundProcess: value.foreground_process,
size: { cols: value.cols, rows: value.rows },
output: { head: value.output_head, tail: value.output_tail },
}
}
function toID(value: number) {
return Pty.ID.make(`pty_persistent_${value}`)
}
function fromID(value: Pty.ID) {
if (!value.startsWith("pty_persistent_")) throw new Error(`invalid persistent PTY ID: ${value}`)
const parsed = Number(value.slice("pty_persistent_".length))
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`invalid persistent PTY ID: ${value}`)
return parsed
}
@@ -0,0 +1,3 @@
const asset: { readonly path: string; readonly version: string; readonly sha256: string } | undefined = undefined
export default asset
+6
View File
@@ -22,7 +22,10 @@ import { Reference } from "./reference.js"
import { Skill } from "./skill.js"
import { State } from "./state.js"
import { Tool } from "./tool.js"
import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
@@ -199,8 +202,11 @@ export const node = makeLocationNode({
Reference.node,
Skill.node,
Tool.node,
Vcs.node,
PluginHooks.node,
PluginRuntime.node,
WebSearch.node,
Generate.node,
Permission.node,
],
})
+3
View File
@@ -5,6 +5,7 @@ import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration"
import type { PermissionHooks } from "@opencode-ai/plugin/effect/permission"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state.js"
@@ -12,6 +13,7 @@ import { State } from "../state.js"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly permission: PermissionHooks
readonly shell: ShellHooks
readonly tool: ToolHooks
}
@@ -22,6 +24,7 @@ type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
interface Failures extends Record<keyof Domains, unknown> {
readonly aisdk: NoFailures<AISDKHooks>
readonly session: NoFailures<SessionHooks>
readonly permission: NoFailures<PermissionHooks>
readonly shell: NoFailures<ShellHooks>
readonly tool: ToolFailures
}
+42
View File
@@ -24,7 +24,10 @@ import { AbsolutePath, type DeepMutable } from "../schema.js"
import { Skill } from "../skill.js"
import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { Vcs } from "../vcs.js"
import { WebSearch } from "../websearch.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
import type { Interface } from "../plugin.js"
@@ -43,7 +46,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const reference = yield* Reference.Service
const skill = yield* Skill.Service
const tools = yield* Tool.Service
const vcs = yield* Vcs.Service
const websearch = yield* WebSearch.Service
const generate = yield* Generate.Service
const permission = yield* Permission.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
const locationInfo = () =>
@@ -186,6 +192,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
event: {
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
},
generate: {
text: (input) => generate.text(input).pipe(Effect.map((text) => ({ text }))),
},
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
@@ -311,6 +320,30 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
})
}),
},
permission: {
hook: (name, callback) => hooks.register("permission", name, callback),
list: (input) => permission.forSession(input.sessionID),
get: (input) =>
permission
.get(input.requestID)
.pipe(
Effect.flatMap((request) =>
request?.sessionID === input.sessionID
? Effect.succeed(request)
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
reply: (input) =>
permission
.get(input.requestID)
.pipe(
Effect.flatMap((request) =>
request?.sessionID === input.sessionID
? permission.reply({ requestID: input.requestID, reply: input.reply, message: input.message })
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
},
plugin: {
list: () => response(plugin.list()),
},
@@ -354,6 +387,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
get: () => response(vcs.info()),
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
status: () => response(vcs.status()),
diff: (input) => response(vcs.diff(input.mode, { context: input.context })),
transform: vcs.transform,
reload: vcs.reload,
},
websearch: {
providers: () => response(websearch.providers()),
query: (input) =>
@@ -407,6 +448,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
.interrupt(input.sessionID, { continue: input.continue })
.pipe(Effect.map((interrupted) => ({ interrupted }))),
wait: (input) => runtime.session.wait(input.sessionID),
context: (input) => runtime.session.context(input.sessionID),
},
} satisfies Plugin.Context
})
+4
View File
@@ -83,8 +83,10 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { PluginRuntime } from "./runtime.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { SystemPromptPlugin } from "./system-prompt.js"
import { VariantPlugin } from "./variant.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
import { WellKnownPlugin } from "../wellknown/plugin.js"
@@ -235,10 +237,12 @@ const pre = [
ConfigMCPPlugin.Plugin,
MCPCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
VcsGitPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
...ProviderPlugins,
@@ -221,7 +221,7 @@ export const GithubCopilotPlugin = define({
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
+1 -1
View File
@@ -301,7 +301,7 @@ export const OpenAIPlugin = define({
{ providerID: Provider.ID.openai },
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
@@ -193,7 +193,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
+2
View File
@@ -26,6 +26,7 @@ export interface Interface {
| "interrupt"
| "synthetic"
| "wait"
| "context"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -81,6 +82,7 @@ export const layerWithCell = (cell: Cell) =>
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
},
job: {
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
@@ -1,20 +1,47 @@
export * as VcsGit from "./git.js"
export * as VcsGitPlugin from "./git.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs.js"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch.js"
import type { Patch } from "./patch.js"
import { Location } from "../../location.js"
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "../../vcs/patch.js"
import type { Patch } from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.git",
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
const processes = yield* AppProcess.Service
const adapter = make(processes, {
directory: location.directory,
worktree: location.project.directory,
})
yield* ctx.vcs.transform((draft) => {
draft.add({
id: "git",
name: "Git",
info: () => adapter.info(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
diff: (input) => adapter.diff(input.mode, { context: input.context }),
})
})
}),
})
/**
* Git adapter for the Vcs service. Ported from the V1 pipeline: patches are
* batched through one `git diff` invocation where possible and capped by
* per-file and total byte budgets, falling back to empty patches when capped.
*/
export function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Interface {
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Adapter {
// Listing commands scope pathspecs to the requested directory; per-file
// commands run from the worktree root because git lists root-relative paths.
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
@@ -26,6 +53,9 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor
})
return { branch: { current, default: root?.name } } satisfies Info
}),
branches: Effect.fn("VcsGit.branches")(function* (options?: BranchOptions) {
return yield* ctx.git.branches(ctx.directory, options)
}),
status: Effect.fn("VcsGit.status")(function* () {
const git = ctx.git
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
@@ -176,6 +206,22 @@ function makeGit(proc: AppProcess.Interface) {
return result.text().trim() || undefined
})
const branches = Effect.fn("VcsGit.branches")(function* (cwd: string, options?: BranchOptions) {
const search = options?.search?.trim().replace(/[*?[\]\\]/g, "\\$&")
return (yield* lines(
[
"for-each-ref",
"--ignore-case",
"--sort=refname",
"--sort=-committerdate",
"--format=%(refname:short)",
...(options?.limit ? [`--count=${options.limit}`] : []),
...(search ? [`refs/heads/*${search}*`, `refs/remotes/*${search}*`] : ["refs/heads", "refs/remotes"]),
],
{ cwd },
)).filter((item) => !item.endsWith("/HEAD")) satisfies BranchList
})
const defaultBranch = Effect.fn("VcsGit.defaultBranch")(function* (cwd: string) {
const remote = yield* primary(cwd)
if (remote) {
@@ -313,6 +359,7 @@ function makeGit(proc: AppProcess.Interface) {
return {
branch,
branches,
defaultBranch,
hasHead,
mergeBase,
@@ -1,13 +1,15 @@
export * as VcsHg from "./hg.js"
export * as VcsHgPlugin from "./hg.js"
import path from "path"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs.js"
import { Location } from "../../location.js"
import type { Adapter, DiffOptions } from "../../vcs.js"
import {
addPatch,
chunksByFile,
@@ -17,18 +19,44 @@ import {
MAX_PATCH_BYTES,
MAX_TOTAL_PATCH_BYTES,
PATCH_CONTEXT_LINES,
} from "./patch.js"
} from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.hg",
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
const processes = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const adapter = make(processes, fs, {
directory: location.directory,
worktree: location.project.directory,
})
yield* ctx.vcs.transform((draft) => {
draft.add({
id: "hg",
name: "Mercurial",
info: () => adapter.info(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
diff: (input) => adapter.diff(input.mode, { context: input.context }),
})
})
}),
})
/**
* Mercurial adapter for the Vcs service. `hg diff --git` emits git-format
* patches for tracked changes; untracked (`?`) and missing (`!`) files never
* appear in `hg diff`, so their patches are synthesized from file contents.
*/
export function make(
function make(
proc: AppProcess.Interface,
fs: FSUtil.Interface,
input: { directory: string; worktree: string },
): Interface {
): Adapter {
const hg = makeHg(proc, input.worktree)
// All commands run from the worktree root (hg prints root-relative paths);
// this pathspec scopes them to the requested directory.
@@ -76,6 +104,9 @@ export function make(
info: Effect.fn("VcsHg.info")(function* () {
return { branch: { current: yield* hg.branch(), default: "default" } } satisfies Info
}),
branches: Effect.fn("VcsHg.branches")(function* () {
return []
}),
status: Effect.fn("VcsHg.status")(function* () {
const [items, batch] = yield* Effect.all(
// Zero-context patches are enough to count changed lines.
+44 -1
View File
@@ -29,6 +29,13 @@ export type Current = ProjectSchema.Current
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = ProjectSchema.UpdateInput
export type UpdateInput = ProjectSchema.UpdateInput
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Project.NotFoundError", {
projectID: ID,
}) {}
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -47,6 +54,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
}
@@ -88,7 +96,17 @@ const layer = Layer.effect(
const announcing = new Set<string>()
const persist = Effect.fnUntraced(function* (project: Resolved) {
const previous = yield* db
.select({ canonical: ProjectTable.worktree })
.from(ProjectTable)
.where(eq(ProjectTable.id, project.id))
.get()
.pipe(Effect.orDie)
yield* upsertProject(db, project).pipe(Effect.orDie)
if (previous && previous.canonical !== project.canonical) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, project.id)).get().pipe(Effect.orDie)
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
}
if (!project.vcs) return project
const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [
{ projectID: project.id, directory: project.canonical },
@@ -145,6 +163,31 @@ const layer = Layer.effect(
return rows.map(fromRow)
})
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
const row = yield* db
.update(ProjectTable)
.set({
name: input.name === undefined ? undefined : input.name || null,
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
commands:
input.commands?.start === undefined
? undefined
: input.commands.start
? { start: input.commands.start }
: null,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ projectID: input.projectID })
const project = fromRow(row)
yield* bus.publish(ProjectSchema.Event.Updated, project)
return project
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -258,7 +301,7 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, resolve })
return Service.of({ list, update, resolve })
}),
)
+5
View File
@@ -13,6 +13,11 @@ export type Current = typeof Current.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = Project.UpdateInput
export type UpdateInput = typeof UpdateInput.Type
export const Event = Project.Event
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),

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