Compare commits

..

21 Commits

Author SHA1 Message Date
Kit Langton 87786c5e88 feat(voice): add session archive control 2026-07-28 11:09:14 -04:00
Brendan Allan 62c47f467f chore(client): regenerate project API 2026-07-23 12:06:18 +08:00
Brendan Allan 639a5fe5b1 test(project): update session project mock 2026-07-23 12:06:02 +08:00
Brendan Allan 9afd059fa1 feat(project): support project metadata updates 2026-07-23 12:05:51 +08:00
Brendan Allan 6bbf50b940 chore(client): regenerate session API 2026-07-23 12:05:43 +08:00
Brendan Allan ba2dc65d6b fix(session): keep archival project mock valid 2026-07-23 12:05:41 +08:00
Brendan Allan 08e616bcad feat(session): add durable session archival 2026-07-23 12:05:31 +08:00
Brendan Allan a6761d4fe0 chore(client): regenerate VCS API 2026-07-23 12:05:24 +08:00
Brendan Allan 305466e5ef feat(vcs): expose repository branch metadata 2026-07-23 12:05:12 +08:00
Brendan Allan 704e9ff3fb chore(client): regenerate PTY API 2026-07-23 12:05:05 +08:00
Brendan Allan dbe7b937ef feat(pty): expose shells and connect tokens 2026-07-23 12:04:43 +08:00
Brendan Allan 9de001d92f chore(client): regenerate command API 2026-07-23 12:04:37 +08:00
Brendan Allan 3cd894d40a feat(command): identify command sources 2026-07-23 12:04:35 +08:00
Brendan Allan 0e705e2a05 chore(client): regenerate path API 2026-07-23 12:04:25 +08:00
Brendan Allan 421d649019 fix(protocol): name path client group 2026-07-23 12:04:06 +08:00
Brendan Allan a7855aa169 feat(server): expose location paths 2026-07-23 12:03:46 +08:00
opencode-agent[bot] 6e8aefcfa0 fix(ai): normalize Bedrock cache usage (#38427)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 21:42:53 -05:00
James Long f1f0f47ee2 fix(core): migrate named agent colors (#38414) 2026-07-22 22:38:13 -04:00
Kit Langton 203b9f59b7 fix(core): load dynamic models for generation (#38401) 2026-07-22 22:26:08 -04:00
Aiden Cline 48bcbd09ef fix(ai): handle incomplete responses without reasons (#38374) 2026-07-22 20:49:08 -05:00
James Long d86f732df3 refactor(tui): generate syntax from V2 theme (#38397) 2026-07-22 21:33:25 -04:00
112 changed files with 6758 additions and 1176 deletions
+28
View File
@@ -470,7 +470,9 @@
"packages/docs": {
"name": "@opencode-ai/docs",
"devDependencies": {
"effect": "catalog:",
"mint": "4.2.666",
"prettier": "3.6.2",
},
},
"packages/effect-drizzle-sqlite": {
@@ -1005,6 +1007,24 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/voice": {
"name": "@opencode-ai/voice",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"effect": "catalog:",
"opentui-spinner": "catalog:",
"solid-js": "1.9.12",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.4",
@@ -2137,6 +2157,8 @@
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
"@opencode-ai/voice": ["@opencode-ai/voice@workspace:packages/voice"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@opencode-ai/www": ["@opencode-ai/www@workspace:packages/www"],
@@ -6605,6 +6627,8 @@
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode-ai/voice/solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opencode-ai/www/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.44.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260708.1", "unenv": "2.0.0-rc.24", "wrangler": "4.110.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-8wGGunqRcs34o4GRq0Rurp7GZg30xtLJeRGUU81a49r9zQRjlp3xIlsWr3nFlSCso4eE3cjZfiKC/2y116M4TQ=="],
@@ -7885,6 +7909,10 @@
"@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode-ai/voice/solid-js/seroval": ["seroval@1.5.5", "", {}, "sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw=="],
"@opencode-ai/voice/solid-js/seroval-plugins": ["seroval-plugins@1.5.5", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-+BDhqYM6CEn3x09v44dpa9p6974FuUB2dxk+Ctn04k0cO1Zt6QODTXfmEZK0eBaTe/fJBvP4NMGuNJ+R8T+QMg=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
+10 -9
View File
@@ -436,21 +436,22 @@ const mapFinishReason = (reason: string): FinishReason => {
return "unknown"
}
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
// the total through and derive the non-cached breakdown. Bedrock does
// not break reasoning out of `outputTokens` for any current model.
// AWS reports inputTokens separately from cache reads and writes.
// Bedrock does not break reasoning out of outputTokens for current models.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
if (!usage) return undefined
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
const inputTokens = ProviderShared.sumTokens(
usage.inputTokens,
usage.cacheReadInputTokens,
usage.cacheWriteInputTokens,
)
return new Usage({
inputTokens: usage.inputTokens,
inputTokens,
outputTokens: usage.outputTokens,
nonCachedInputTokens: nonCached,
nonCachedInputTokens: usage.inputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
})
}
+11 -58
View File
@@ -62,9 +62,6 @@ const OpenAIResponsesOutputText = Schema.Struct({
text: Schema.String,
})
const OpenAIResponsesMessagePhase = Schema.Literals(["commentary", "final_answer"])
type OpenAIResponsesMessagePhase = Schema.Schema.Type<typeof OpenAIResponsesMessagePhase>
const OpenAIResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
@@ -99,11 +96,7 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("assistant"),
content: Schema.Array(OpenAIResponsesOutputText),
phase: optionalNull(OpenAIResponsesMessagePhase),
}),
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
OpenAIResponsesReasoningItem,
OpenAIResponsesItemReference,
Schema.Struct({
@@ -223,7 +216,6 @@ const OpenAIResponsesStreamItem = Schema.Struct({
// call's typed input portion and round-trip the full result payload without
// hand-rolling a per-tool schema.
status: Schema.optional(Schema.String),
phase: optionalNull(OpenAIResponsesMessagePhase),
action: Schema.optional(Schema.Unknown),
queries: Schema.optional(Schema.Unknown),
results: Schema.optional(Schema.Unknown),
@@ -261,7 +253,7 @@ const OpenAIResponsesEvent = Schema.Struct({
Schema.Struct({
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
usage: optionalNull(OpenAIResponsesUsage),
error: optionalNull(OpenAIResponsesErrorPayload),
}),
@@ -279,7 +271,6 @@ interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly textItems: Readonly<Record<string, ProviderMetadata>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
@@ -404,7 +395,10 @@ const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultCon
provider: string,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }, provider)
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
provider,
)
})
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (
@@ -448,29 +442,16 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
if (message.role === "assistant") {
const content: TextPart[] = []
let phase: OpenAIResponsesMessagePhase | undefined
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
input.push({
role: "assistant",
content: content.map((part) => ({ type: "output_text", text: part.text })),
...(phase === undefined ? {} : { phase }),
})
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
content.splice(0, content.length)
phase = undefined
}
for (const part of message.content) {
if (part.type === "text") {
const openai = part.providerMetadata?.openai
const nextPhase =
ProviderShared.isRecord(openai) && (openai.phase === "commentary" || openai.phase === "final_answer")
? openai.phase
: undefined
if (content.length > 0 && phase !== nextPhase) flushText()
phase = nextPhase
content.push(part)
continue
}
@@ -621,7 +602,8 @@ const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => {
const reason = event.response?.incomplete_details?.reason
if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop"
if (reason === undefined || reason === null)
return hasFunctionCall ? "tool-calls" : event.type === "response.incomplete" ? "unknown" : "stop"
if (reason === "max_output_tokens") return "length"
if (reason === "content_filter") return "content-filter"
return hasFunctionCall ? "tool-calls" : "unknown"
@@ -728,28 +710,15 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const itemID = event.item_id ?? "text-0"
return [
{
...state,
lifecycle: Lifecycle.textDelta(state.lifecycle, events, itemID, event.delta, state.textItems[itemID]),
},
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
events,
]
}
const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const events: LLMEvent[] = []
const itemID = event.item_id ?? "text-0"
const { [itemID]: _completed, ...textItems } = state.textItems
return [
{
...state,
lifecycle: Lifecycle.textEnd(state.lifecycle, events, itemID, state.textItems[itemID]),
textItems,
},
events,
]
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
}
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
@@ -786,21 +755,6 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const item = event.item
if (item?.type === "message" && item.id) {
return [
{
...state,
textItems: {
...state.textItems,
[item.id]: openaiMetadata({
itemId: item.id,
...(item.phase === undefined || item.phase === null ? {} : { phase: item.phase }),
}),
},
},
NO_EVENTS,
]
}
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -1110,7 +1064,6 @@ export const protocol = Protocol.make({
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
textItems: {},
reasoningItems: {},
store: OpenAIOptions.store(request),
}),
+6 -15
View File
@@ -14,25 +14,16 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true }
}
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (state.text.has(id)) return state
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const stepped = stepStart(state, events)
events.push(LLMEvent.textStart({ id, providerMetadata }))
if (stepped.text.has(id)) {
events.push(LLMEvent.textDelta({ id, text }))
return stepped
}
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (
state: State,
events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
): State => {
const started = textStart(state, events, id, providerMetadata)
events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
return started
}
export const reasoningStart = (
state: State,
events: LLMEvent[],
+4 -3
View File
@@ -34,11 +34,12 @@ import { ProviderFailureClassification } from "./errors"
*
* **Semantics by provider**:
*
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
* - OpenAI Chat / Responses / Gemini: provider reports inclusive
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
* derive the breakdown.
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
* mappers sum the breakdown to derive the inclusive `inputTokens`.
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
* `reasoningTokens` is `undefined` and `outputTokens` carries the
* combined total — a documented limitation of the Anthropic API.
File diff suppressed because one or more lines are too long
@@ -13,12 +13,8 @@ const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
// call wouldn't deterministically prove cache mapping works. Override with
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
const model = AmazonBedrock.configure({
credentials: {
region: RECORDING_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
sessionToken: process.env.AWS_SESSION_TOKEN,
},
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: RECORDING_REGION,
}).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0")
const cacheRequest = LLM.request({
@@ -36,7 +32,7 @@ const recorded = recordedTests({
prefix: "bedrock-converse-cache",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
// Two identical requests in one cassette — replay walks the cassette in
// recording order so the second call replays the cached-hit interaction.
})
@@ -45,10 +41,20 @@ describe("Bedrock Converse cache recorded", () => {
recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(cacheRequest)
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
expect(first.usage?.cacheWriteInputTokens ?? 0).toBeGreaterThan(0)
expect(first.usage?.inputTokens).toBe(
(first.usage?.nonCachedInputTokens ?? 0) +
(first.usage?.cacheReadInputTokens ?? 0) +
(first.usage?.cacheWriteInputTokens ?? 0),
)
const second = yield* LLMClient.generate(cacheRequest)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
expect(second.usage?.inputTokens).toBe(
(second.usage?.nonCachedInputTokens ?? 0) +
(second.usage?.cacheReadInputTokens ?? 0) +
(second.usage?.cacheWriteInputTokens ?? 0),
)
}),
)
})
@@ -269,6 +269,39 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("adds cache reads and writes to Bedrock input usage", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
[
"metadata",
{
usage: {
inputTokens: 5,
outputTokens: 2,
totalTokens: 12,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
},
},
],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.usage).toMatchObject({
inputTokens: 10,
nonCachedInputTokens: 5,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
outputTokens: 2,
totalTokens: 12,
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -870,6 +870,32 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("maps incomplete response reasons", () =>
Effect.gen(function* () {
const generate = (incompleteDetails: object) =>
LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.incomplete",
response: { id: "resp_incomplete", incomplete_details: incompleteDetails },
}),
),
),
)
const length = yield* generate({ reason: "max_output_tokens" })
const contentFilter = yield* generate({ reason: "content_filter" })
const unknown = yield* generate({})
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
"length",
"content-filter",
"unknown",
])
}),
)
// OpenAI's documented stream orders output text within one message item; no
// provider-valid same-kind overlap is evidenced, so done boundaries close it.
it.effect("closes sequential output messages before starting the next", () =>
@@ -899,66 +925,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves output message phases in follow-up requests", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "message", id: "msg_commentary", phase: "commentary" },
},
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
{ type: "response.output_text.done", item_id: "msg_commentary" },
{
type: "response.output_item.added",
item: { type: "message", id: "msg_final", phase: "final_answer" },
},
{ type: "response.output_text.delta", item_id: "msg_final", delta: "Done." },
{ type: "response.output_text.done", item_id: "msg_final" },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
},
{
type: "text",
text: "Done.",
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
},
])
const followUp = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [Message.user("Start"), response.message, Message.user("Continue")],
}),
)
expect(followUp.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Start" }] },
{
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Checking." }],
},
{
role: "assistant",
phase: "final_answer",
content: [{ type: "output_text", text: "Done." }],
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
])
}),
)
it.effect("parses reasoning summary stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
+210 -161
View File
@@ -124,185 +124,190 @@ export type Endpoint5_8Input = {
export type Endpoint5_8Output = EffectValue<ReturnType<RawClient["server.session"]["session.rename"]>>
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.move"]>[0]
export type Endpoint5_9Input = {
readonly sessionID: Endpoint5_9Request["params"]["sessionID"]
readonly directory: Endpoint5_9Request["payload"]["directory"]
readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"]
}
export type Endpoint5_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.move"]>>
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.archive"]>[0]
export type Endpoint5_9Input = { readonly sessionID: Endpoint5_9Request["params"]["sessionID"] }
export type Endpoint5_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.archive"]>>
export type SessionArchiveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
type Endpoint5_10Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint5_10Request = Parameters<RawClient["server.session"]["session.move"]>[0]
export type Endpoint5_10Input = {
readonly sessionID: Endpoint5_10Request["params"]["sessionID"]
readonly id?: Endpoint5_10Request["payload"]["id"]
readonly text: Endpoint5_10Request["payload"]["text"]
readonly files?: Endpoint5_10Request["payload"]["files"]
readonly agents?: Endpoint5_10Request["payload"]["agents"]
readonly metadata?: Endpoint5_10Request["payload"]["metadata"]
readonly delivery?: Endpoint5_10Request["payload"]["delivery"]
readonly resume?: Endpoint5_10Request["payload"]["resume"]
readonly directory: Endpoint5_10Request["payload"]["directory"]
readonly workspaceID?: Endpoint5_10Request["payload"]["workspaceID"]
}
export type Endpoint5_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.prompt"]>>["data"]
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.move"]>>
export type SessionMoveOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
type Endpoint5_11Request = Parameters<RawClient["server.session"]["session.command"]>[0]
type Endpoint5_11Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
export type Endpoint5_11Input = {
readonly sessionID: Endpoint5_11Request["params"]["sessionID"]
readonly id?: Endpoint5_11Request["payload"]["id"]
readonly command: Endpoint5_11Request["payload"]["command"]
readonly arguments?: Endpoint5_11Request["payload"]["arguments"]
readonly agent?: Endpoint5_11Request["payload"]["agent"]
readonly model?: Endpoint5_11Request["payload"]["model"]
readonly text: Endpoint5_11Request["payload"]["text"]
readonly files?: Endpoint5_11Request["payload"]["files"]
readonly agents?: Endpoint5_11Request["payload"]["agents"]
readonly metadata?: Endpoint5_11Request["payload"]["metadata"]
readonly delivery?: Endpoint5_11Request["payload"]["delivery"]
readonly resume?: Endpoint5_11Request["payload"]["resume"]
}
export type Endpoint5_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.command"]>>["data"]
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.prompt"]>>["data"]
export type SessionPromptOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
type Endpoint5_12Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
type Endpoint5_12Request = Parameters<RawClient["server.session"]["session.command"]>[0]
export type Endpoint5_12Input = {
readonly sessionID: Endpoint5_12Request["params"]["sessionID"]
readonly id?: Endpoint5_12Request["payload"]["id"]
readonly skill: Endpoint5_12Request["payload"]["skill"]
readonly command: Endpoint5_12Request["payload"]["command"]
readonly arguments?: Endpoint5_12Request["payload"]["arguments"]
readonly agent?: Endpoint5_12Request["payload"]["agent"]
readonly model?: Endpoint5_12Request["payload"]["model"]
readonly files?: Endpoint5_12Request["payload"]["files"]
readonly agents?: Endpoint5_12Request["payload"]["agents"]
readonly delivery?: Endpoint5_12Request["payload"]["delivery"]
readonly resume?: Endpoint5_12Request["payload"]["resume"]
}
export type Endpoint5_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.command"]>>["data"]
export type SessionCommandOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
type Endpoint5_13Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
type Endpoint5_13Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
export type Endpoint5_13Input = {
readonly sessionID: Endpoint5_13Request["params"]["sessionID"]
readonly id?: Endpoint5_13Request["payload"]["id"]
readonly text: Endpoint5_13Request["payload"]["text"]
readonly description?: Endpoint5_13Request["payload"]["description"]
readonly metadata?: Endpoint5_13Request["payload"]["metadata"]
readonly delivery?: Endpoint5_13Request["payload"]["delivery"]
readonly skill: Endpoint5_13Request["payload"]["skill"]
readonly resume?: Endpoint5_13Request["payload"]["resume"]
}
export type Endpoint5_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>["data"]
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
export type SessionSkillOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
type Endpoint5_14Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
type Endpoint5_14Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
export type Endpoint5_14Input = {
readonly sessionID: Endpoint5_14Request["params"]["sessionID"]
readonly id?: Endpoint5_14Request["payload"]["id"]
readonly command: Endpoint5_14Request["payload"]["command"]
readonly text: Endpoint5_14Request["payload"]["text"]
readonly description?: Endpoint5_14Request["payload"]["description"]
readonly metadata?: Endpoint5_14Request["payload"]["metadata"]
readonly delivery?: Endpoint5_14Request["payload"]["delivery"]
readonly resume?: Endpoint5_14Request["payload"]["resume"]
}
export type Endpoint5_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>>
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>["data"]
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
type Endpoint5_15Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint5_15Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
export type Endpoint5_15Input = {
readonly sessionID: Endpoint5_15Request["params"]["sessionID"]
readonly id?: Endpoint5_15Request["payload"]["id"]
readonly command: Endpoint5_15Request["payload"]["command"]
}
export type Endpoint5_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>["data"]
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>>
export type SessionShellOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
type Endpoint5_16Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
export type Endpoint5_16Input = { readonly sessionID: Endpoint5_16Request["params"]["sessionID"] }
export type Endpoint5_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
type Endpoint5_17Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
export type Endpoint5_17Input = {
readonly sessionID: Endpoint5_17Request["params"]["sessionID"]
readonly messageID: Endpoint5_17Request["payload"]["messageID"]
readonly files?: Endpoint5_17Request["payload"]["files"]
type Endpoint5_16Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
export type Endpoint5_16Input = {
readonly sessionID: Endpoint5_16Request["params"]["sessionID"]
readonly id?: Endpoint5_16Request["payload"]["id"]
}
export type Endpoint5_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>["data"]
export type SessionCompactOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
type Endpoint5_18Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
export type Endpoint5_18Input = { readonly sessionID: Endpoint5_18Request["params"]["sessionID"] }
export type Endpoint5_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
type Endpoint5_17Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
export type Endpoint5_17Input = { readonly sessionID: Endpoint5_17Request["params"]["sessionID"] }
export type Endpoint5_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
export type SessionWaitOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
type Endpoint5_19Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint5_18Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
export type Endpoint5_18Input = {
readonly sessionID: Endpoint5_18Request["params"]["sessionID"]
readonly messageID: Endpoint5_18Request["payload"]["messageID"]
readonly files?: Endpoint5_18Request["payload"]["files"]
}
export type Endpoint5_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
type Endpoint5_19Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
export type Endpoint5_19Input = { readonly sessionID: Endpoint5_19Request["params"]["sessionID"] }
export type Endpoint5_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
type Endpoint5_20Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint5_20Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
export type Endpoint5_20Input = { readonly sessionID: Endpoint5_20Request["params"]["sessionID"] }
export type Endpoint5_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.context"]>[0]
export type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
export type Endpoint5_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.pending.list"]>>["data"]
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
export type Endpoint5_22Input = { readonly sessionID: Endpoint5_22Request["params"]["sessionID"] }
export type Endpoint5_22Output = EffectValue<
export type Endpoint5_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.pending.list"]>>["data"]
export type SessionPendingListOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
export type Endpoint5_23Input = { readonly sessionID: Endpoint5_23Request["params"]["sessionID"] }
export type Endpoint5_23Output = EffectValue<
ReturnType<RawClient["server.session"]["session.instructions.entry.list"]>
>["data"]
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E>
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
export type Endpoint5_23Input = {
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
readonly key: Endpoint5_23Request["params"]["key"]
readonly value: Endpoint5_23Request["payload"]["value"]
}
export type Endpoint5_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
export type Endpoint5_24Input = {
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
readonly key: Endpoint5_24Request["params"]["key"]
readonly value: Endpoint5_24Request["payload"]["value"]
}
export type Endpoint5_24Output = EffectValue<
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
>
export type SessionInstructionsEntryRemoveOperation<E = never> = (
export type Endpoint5_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.generate"]>[0]
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
export type Endpoint5_25Input = {
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
readonly prompt: Endpoint5_25Request["payload"]["prompt"]
readonly key: Endpoint5_25Request["params"]["key"]
}
export type Endpoint5_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.generate"]>>["data"]
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_25Output = EffectValue<
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
>
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.generate"]>[0]
export type Endpoint5_26Input = {
readonly sessionID: Endpoint5_26Request["params"]["sessionID"]
readonly after?: Endpoint5_26Request["query"]["after"]
readonly follow?: Endpoint5_26Request["query"]["follow"]
readonly prompt: Endpoint5_26Request["payload"]["prompt"]
}
export type Endpoint5_26Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
export type Endpoint5_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.generate"]>>["data"]
export type SessionGenerateOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.log"]>[0]
export type Endpoint5_27Input = {
readonly sessionID: Endpoint5_27Request["params"]["sessionID"]
readonly after?: Endpoint5_27Request["query"]["after"]
readonly follow?: Endpoint5_27Request["query"]["follow"]
}
export type Endpoint5_27Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint5_27Input) => Stream.Stream<Endpoint5_27Output, E>
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint5_28Input = { readonly sessionID: Endpoint5_28Request["params"]["sessionID"] }
export type Endpoint5_28Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_28Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
type Endpoint5_29Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint5_29Input = {
readonly sessionID: Endpoint5_29Request["params"]["sessionID"]
readonly messageID: Endpoint5_29Request["params"]["messageID"]
type Endpoint5_29Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint5_29Input = { readonly sessionID: Endpoint5_29Request["params"]["sessionID"] }
export type Endpoint5_29Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
type Endpoint5_30Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint5_30Input = {
readonly sessionID: Endpoint5_30Request["params"]["sessionID"]
readonly messageID: Endpoint5_30Request["params"]["messageID"]
}
export type Endpoint5_29Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -314,6 +319,7 @@ export interface SessionApi<E = never> {
readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E>
readonly rename: SessionRenameOperation<E>
readonly archive: SessionArchiveOperation<E>
readonly move: SessionMoveOperation<E>
readonly prompt: SessionPromptOperation<E>
readonly command: SessionCommandOperation<E>
@@ -614,21 +620,32 @@ export interface CredentialApi<E = never> {
export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.project"]["project.list"]>>
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
type Endpoint13_1Request = Parameters<RawClient["server.project"]["project.current"]>[0]
export type Endpoint13_1Input = { readonly location?: Endpoint13_1Request["query"]["location"] }
export type Endpoint13_1Output = EffectValue<ReturnType<RawClient["server.project"]["project.current"]>>
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
type Endpoint13_2Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
export type Endpoint13_2Input = {
readonly projectID: Endpoint13_2Request["params"]["projectID"]
readonly location?: Endpoint13_2Request["query"]["location"]
type Endpoint13_1Request = Parameters<RawClient["server.project"]["project.update"]>[0]
export type Endpoint13_1Input = {
readonly projectID: Endpoint13_1Request["params"]["projectID"]
readonly name?: Endpoint13_1Request["payload"]["name"]
readonly icon?: Endpoint13_1Request["payload"]["icon"]
readonly commands?: Endpoint13_1Request["payload"]["commands"]
}
export type Endpoint13_2Output = EffectValue<ReturnType<RawClient["server.project"]["project.directories"]>>
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
export type Endpoint13_1Output = EffectValue<ReturnType<RawClient["server.project"]["project.update"]>>
export type ProjectUpdateOperation<E = never> = (input: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
type Endpoint13_2Request = Parameters<RawClient["server.project"]["project.current"]>[0]
export type Endpoint13_2Input = { readonly location?: Endpoint13_2Request["query"]["location"] }
export type Endpoint13_2Output = EffectValue<ReturnType<RawClient["server.project"]["project.current"]>>
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
type Endpoint13_3Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
export type Endpoint13_3Input = {
readonly projectID: Endpoint13_3Request["params"]["projectID"]
readonly location?: Endpoint13_3Request["query"]["location"]
}
export type Endpoint13_3Output = EffectValue<ReturnType<RawClient["server.project"]["project.directories"]>>
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_3Input) => Effect.Effect<Endpoint13_3Output, E>
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly update: ProjectUpdateOperation<E>
readonly current: ProjectCurrentOperation<E>
readonly directories: ProjectDirectoriesOperation<E>
}
@@ -820,55 +837,71 @@ export interface EventApi<E = never> {
readonly subscribe: EventSubscribeOperation<E>
}
type Endpoint20_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint20_0Request = Parameters<RawClient["server.pty"]["pty.shells"]>[0]
export type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
export type Endpoint20_0Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.list"]>>
export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
export type Endpoint20_0Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.shells"]>>
export type PtyShellsOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
type Endpoint20_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
export type Endpoint20_1Input = {
readonly location?: Endpoint20_1Request["query"]["location"]
readonly command?: Endpoint20_1Request["payload"]["command"]
readonly args?: Endpoint20_1Request["payload"]["args"]
readonly cwd?: Endpoint20_1Request["payload"]["cwd"]
readonly title?: Endpoint20_1Request["payload"]["title"]
readonly env?: Endpoint20_1Request["payload"]["env"]
}
export type Endpoint20_1Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.create"]>>
export type PtyCreateOperation<E = never> = (input?: Endpoint20_1Input) => Effect.Effect<Endpoint20_1Output, E>
type Endpoint20_1Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
export type Endpoint20_1Input = { readonly location?: Endpoint20_1Request["query"]["location"] }
export type Endpoint20_1Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.list"]>>
export type PtyListOperation<E = never> = (input?: Endpoint20_1Input) => Effect.Effect<Endpoint20_1Output, E>
type Endpoint20_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint20_2Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
export type Endpoint20_2Input = {
readonly ptyID: Endpoint20_2Request["params"]["ptyID"]
readonly location?: Endpoint20_2Request["query"]["location"]
readonly command?: Endpoint20_2Request["payload"]["command"]
readonly args?: Endpoint20_2Request["payload"]["args"]
readonly cwd?: Endpoint20_2Request["payload"]["cwd"]
readonly title?: Endpoint20_2Request["payload"]["title"]
readonly env?: Endpoint20_2Request["payload"]["env"]
}
export type Endpoint20_2Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.get"]>>
export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
export type Endpoint20_2Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.create"]>>
export type PtyCreateOperation<E = never> = (input?: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
type Endpoint20_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint20_3Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
export type Endpoint20_3Input = {
readonly ptyID: Endpoint20_3Request["params"]["ptyID"]
readonly location?: Endpoint20_3Request["query"]["location"]
readonly title?: Endpoint20_3Request["payload"]["title"]
readonly size?: Endpoint20_3Request["payload"]["size"]
}
export type Endpoint20_3Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.update"]>>
export type PtyUpdateOperation<E = never> = (input: Endpoint20_3Input) => Effect.Effect<Endpoint20_3Output, E>
export type Endpoint20_3Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.get"]>>
export type PtyGetOperation<E = never> = (input: Endpoint20_3Input) => Effect.Effect<Endpoint20_3Output, E>
type Endpoint20_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint20_4Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
export type Endpoint20_4Input = {
readonly ptyID: Endpoint20_4Request["params"]["ptyID"]
readonly location?: Endpoint20_4Request["query"]["location"]
readonly title?: Endpoint20_4Request["payload"]["title"]
readonly size?: Endpoint20_4Request["payload"]["size"]
}
export type Endpoint20_4Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.remove"]>>
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
export type Endpoint20_4Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.update"]>>
export type PtyUpdateOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
type Endpoint20_5Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
export type Endpoint20_5Input = {
readonly ptyID: Endpoint20_5Request["params"]["ptyID"]
readonly location?: Endpoint20_5Request["query"]["location"]
}
export type Endpoint20_5Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.remove"]>>
export type PtyRemoveOperation<E = never> = (input: Endpoint20_5Input) => Effect.Effect<Endpoint20_5Output, E>
type Endpoint20_6Request = Parameters<RawClient["server.pty"]["pty.connectToken"]>[0]
export type Endpoint20_6Input = {
readonly ptyID: Endpoint20_6Request["params"]["ptyID"]
readonly location?: Endpoint20_6Request["query"]["location"]
readonly "x-opencode-ticket"?: Endpoint20_6Request["headers"]["x-opencode-ticket"]
}
export type Endpoint20_6Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.connectToken"]>>
export type PtyConnectTokenOperation<E = never> = (input: Endpoint20_6Input) => Effect.Effect<Endpoint20_6Output, E>
export interface PtyApi<E = never> {
readonly shells: PtyShellsOperation<E>
readonly list: PtyListOperation<E>
readonly create: PtyCreateOperation<E>
readonly get: PtyGetOperation<E>
readonly update: PtyUpdateOperation<E>
readonly remove: PtyRemoveOperation<E>
readonly connectToken: PtyConnectTokenOperation<E>
}
type Endpoint21_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
@@ -1011,32 +1044,47 @@ export interface ProjectCopyApi<E = never> {
readonly refresh: ProjectCopyRefreshOperation<E>
}
type Endpoint25_0Request = Parameters<RawClient["server.vcs"]["vcs.status"]>[0]
type Endpoint25_0Request = Parameters<RawClient["server.vcs"]["vcs.get"]>[0]
export type Endpoint25_0Input = { readonly location?: Endpoint25_0Request["query"]["location"] }
export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.vcs"]["vcs.status"]>>
export type VcsStatusOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.vcs"]["vcs.get"]>>
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
type Endpoint25_1Request = Parameters<RawClient["server.vcs"]["vcs.diff"]>[0]
export type Endpoint25_1Input = {
readonly location?: Endpoint25_1Request["query"]["location"]
readonly mode: Endpoint25_1Request["query"]["mode"]
readonly context?: Endpoint25_1Request["query"]["context"]
type Endpoint25_1Request = Parameters<RawClient["server.vcs"]["vcs.status"]>[0]
export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] }
export type Endpoint25_1Output = EffectValue<ReturnType<RawClient["server.vcs"]["vcs.status"]>>
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
type Endpoint25_2Request = Parameters<RawClient["server.vcs"]["vcs.diff"]>[0]
export type Endpoint25_2Input = {
readonly location?: Endpoint25_2Request["query"]["location"]
readonly mode: Endpoint25_2Request["query"]["mode"]
readonly context?: Endpoint25_2Request["query"]["context"]
}
export type Endpoint25_1Output = EffectValue<ReturnType<RawClient["server.vcs"]["vcs.diff"]>>
export type VcsDiffOperation<E = never> = (input: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Output = EffectValue<ReturnType<RawClient["server.vcs"]["vcs.diff"]>>
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly status: VcsStatusOperation<E>
readonly diff: VcsDiffOperation<E>
}
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
type Endpoint26_0Request = Parameters<RawClient["server.path"]["path.get"]>[0]
export type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.path"]["path.get"]>>
export type PathGetOperation<E = never> = (input?: Endpoint26_0Input) => Effect.Effect<Endpoint26_0Output, E>
type Endpoint26_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
export type Endpoint26_1Input = { readonly location?: Endpoint26_1Request["query"]["location"] }
export type Endpoint26_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
export interface PathApi<E = never> {
readonly get: PathGetOperation<E>
}
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
type Endpoint27_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
export type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
@@ -1069,5 +1117,6 @@ export interface AppApi<E = never> {
readonly reference: ReferenceApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly path: PathApi<E>
readonly debug: DebugApi<E>
}
+245 -188
View File
@@ -151,30 +151,35 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp
Effect.mapError(mapClientError),
)
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.move"]>[0]
type Endpoint5_9Input = {
readonly sessionID: Endpoint5_9Request["params"]["sessionID"]
readonly directory: Endpoint5_9Request["payload"]["directory"]
readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"]
}
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.archive"]>[0]
type Endpoint5_9Input = { readonly sessionID: Endpoint5_9Request["params"]["sessionID"] }
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
raw["session.archive"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_10Request = Parameters<RawClient["server.session"]["session.move"]>[0]
type Endpoint5_10Input = {
readonly sessionID: Endpoint5_10Request["params"]["sessionID"]
readonly directory: Endpoint5_10Request["payload"]["directory"]
readonly workspaceID?: Endpoint5_10Request["payload"]["workspaceID"]
}
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint5_10Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint5_10Input = {
readonly sessionID: Endpoint5_10Request["params"]["sessionID"]
readonly id?: Endpoint5_10Request["payload"]["id"]
readonly text: Endpoint5_10Request["payload"]["text"]
readonly files?: Endpoint5_10Request["payload"]["files"]
readonly agents?: Endpoint5_10Request["payload"]["agents"]
readonly metadata?: Endpoint5_10Request["payload"]["metadata"]
readonly delivery?: Endpoint5_10Request["payload"]["delivery"]
readonly resume?: Endpoint5_10Request["payload"]["resume"]
type Endpoint5_11Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint5_11Input = {
readonly sessionID: Endpoint5_11Request["params"]["sessionID"]
readonly id?: Endpoint5_11Request["payload"]["id"]
readonly text: Endpoint5_11Request["payload"]["text"]
readonly files?: Endpoint5_11Request["payload"]["files"]
readonly agents?: Endpoint5_11Request["payload"]["agents"]
readonly metadata?: Endpoint5_11Request["payload"]["metadata"]
readonly delivery?: Endpoint5_11Request["payload"]["delivery"]
readonly resume?: Endpoint5_11Request["payload"]["resume"]
}
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -191,20 +196,20 @@ const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10I
Effect.map((value) => value.data),
)
type Endpoint5_11Request = Parameters<RawClient["server.session"]["session.command"]>[0]
type Endpoint5_11Input = {
readonly sessionID: Endpoint5_11Request["params"]["sessionID"]
readonly id?: Endpoint5_11Request["payload"]["id"]
readonly command: Endpoint5_11Request["payload"]["command"]
readonly arguments?: Endpoint5_11Request["payload"]["arguments"]
readonly agent?: Endpoint5_11Request["payload"]["agent"]
readonly model?: Endpoint5_11Request["payload"]["model"]
readonly files?: Endpoint5_11Request["payload"]["files"]
readonly agents?: Endpoint5_11Request["payload"]["agents"]
readonly delivery?: Endpoint5_11Request["payload"]["delivery"]
readonly resume?: Endpoint5_11Request["payload"]["resume"]
type Endpoint5_12Request = Parameters<RawClient["server.session"]["session.command"]>[0]
type Endpoint5_12Input = {
readonly sessionID: Endpoint5_12Request["params"]["sessionID"]
readonly id?: Endpoint5_12Request["payload"]["id"]
readonly command: Endpoint5_12Request["payload"]["command"]
readonly arguments?: Endpoint5_12Request["payload"]["arguments"]
readonly agent?: Endpoint5_12Request["payload"]["agent"]
readonly model?: Endpoint5_12Request["payload"]["model"]
readonly files?: Endpoint5_12Request["payload"]["files"]
readonly agents?: Endpoint5_12Request["payload"]["agents"]
readonly delivery?: Endpoint5_12Request["payload"]["delivery"]
readonly resume?: Endpoint5_12Request["payload"]["resume"]
}
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -223,30 +228,30 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
Effect.map((value) => value.data),
)
type Endpoint5_12Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
type Endpoint5_12Input = {
readonly sessionID: Endpoint5_12Request["params"]["sessionID"]
readonly id?: Endpoint5_12Request["payload"]["id"]
readonly skill: Endpoint5_12Request["payload"]["skill"]
readonly resume?: Endpoint5_12Request["payload"]["resume"]
type Endpoint5_13Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
type Endpoint5_13Input = {
readonly sessionID: Endpoint5_13Request["params"]["sessionID"]
readonly id?: Endpoint5_13Request["payload"]["id"]
readonly skill: Endpoint5_13Request["payload"]["skill"]
readonly resume?: Endpoint5_13Request["payload"]["resume"]
}
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint5_13Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
type Endpoint5_13Input = {
readonly sessionID: Endpoint5_13Request["params"]["sessionID"]
readonly id?: Endpoint5_13Request["payload"]["id"]
readonly text: Endpoint5_13Request["payload"]["text"]
readonly description?: Endpoint5_13Request["payload"]["description"]
readonly metadata?: Endpoint5_13Request["payload"]["metadata"]
readonly delivery?: Endpoint5_13Request["payload"]["delivery"]
readonly resume?: Endpoint5_13Request["payload"]["resume"]
type Endpoint5_14Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
type Endpoint5_14Input = {
readonly sessionID: Endpoint5_14Request["params"]["sessionID"]
readonly id?: Endpoint5_14Request["payload"]["id"]
readonly text: Endpoint5_14Request["payload"]["text"]
readonly description?: Endpoint5_14Request["payload"]["description"]
readonly metadata?: Endpoint5_14Request["payload"]["metadata"]
readonly delivery?: Endpoint5_14Request["payload"]["delivery"]
readonly resume?: Endpoint5_14Request["payload"]["resume"]
}
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -262,41 +267,41 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
Effect.map((value) => value.data),
)
type Endpoint5_14Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
type Endpoint5_14Input = {
readonly sessionID: Endpoint5_14Request["params"]["sessionID"]
readonly id?: Endpoint5_14Request["payload"]["id"]
readonly command: Endpoint5_14Request["payload"]["command"]
type Endpoint5_15Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
type Endpoint5_15Input = {
readonly sessionID: Endpoint5_15Request["params"]["sessionID"]
readonly id?: Endpoint5_15Request["payload"]["id"]
readonly command: Endpoint5_15Request["payload"]["command"]
}
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint5_15Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint5_15Input = {
readonly sessionID: Endpoint5_15Request["params"]["sessionID"]
readonly id?: Endpoint5_15Request["payload"]["id"]
type Endpoint5_16Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint5_16Input = {
readonly sessionID: Endpoint5_16Request["params"]["sessionID"]
readonly id?: Endpoint5_16Request["payload"]["id"]
}
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint5_16Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint5_16Input = { readonly sessionID: Endpoint5_16Request["params"]["sessionID"] }
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
type Endpoint5_17Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint5_17Input = { readonly sessionID: Endpoint5_17Request["params"]["sessionID"] }
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_17Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint5_17Input = {
readonly sessionID: Endpoint5_17Request["params"]["sessionID"]
readonly messageID: Endpoint5_17Request["payload"]["messageID"]
readonly files?: Endpoint5_17Request["payload"]["files"]
type Endpoint5_18Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint5_18Input = {
readonly sessionID: Endpoint5_18Request["params"]["sessionID"]
readonly messageID: Endpoint5_18Request["payload"]["messageID"]
readonly files?: Endpoint5_18Request["payload"]["files"]
}
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -305,80 +310,80 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
Effect.map((value) => value.data),
)
type Endpoint5_18Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint5_18Input = { readonly sessionID: Endpoint5_18Request["params"]["sessionID"] }
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_19Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint5_19Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint5_19Input = { readonly sessionID: Endpoint5_19Request["params"]["sessionID"] }
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_20Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint5_20Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint5_20Input = { readonly sessionID: Endpoint5_20Request["params"]["sessionID"] }
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
type Endpoint5_22Input = { readonly sessionID: Endpoint5_22Request["params"]["sessionID"] }
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
type Endpoint5_22Input = { readonly sessionID: Endpoint5_22Request["params"]["sessionID"] }
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
type Endpoint5_23Input = { readonly sessionID: Endpoint5_23Request["params"]["sessionID"] }
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
type Endpoint5_23Input = {
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
readonly key: Endpoint5_23Request["params"]["key"]
readonly value: Endpoint5_23Request["payload"]["value"]
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
type Endpoint5_24Input = {
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
readonly key: Endpoint5_24Request["params"]["key"]
readonly value: Endpoint5_24Request["payload"]["value"]
}
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
type Endpoint5_24Input = {
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
readonly key: Endpoint5_24Request["params"]["key"]
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
type Endpoint5_25Input = {
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
readonly key: Endpoint5_25Request["params"]["key"]
}
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.generate"]>[0]
type Endpoint5_25Input = {
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
readonly prompt: Endpoint5_25Request["payload"]["prompt"]
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.generate"]>[0]
type Endpoint5_26Input = {
readonly sessionID: Endpoint5_26Request["params"]["sessionID"]
readonly prompt: Endpoint5_26Request["payload"]["prompt"]
}
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_26Input = {
readonly sessionID: Endpoint5_26Request["params"]["sessionID"]
readonly after?: Endpoint5_26Request["query"]["after"]
readonly follow?: Endpoint5_26Request["query"]["follow"]
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_27Input = {
readonly sessionID: Endpoint5_27Request["params"]["sessionID"]
readonly after?: Endpoint5_27Request["query"]["after"]
readonly follow?: Endpoint5_27Request["query"]["follow"]
}
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -389,22 +394,22 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
),
)
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint5_28Input = { readonly sessionID: Endpoint5_28Request["params"]["sessionID"] }
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_29Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_29Input = { readonly sessionID: Endpoint5_29Request["params"]["sessionID"] }
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_29Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint5_29Input = {
readonly sessionID: Endpoint5_29Request["params"]["sessionID"]
readonly messageID: Endpoint5_29Request["params"]["messageID"]
type Endpoint5_30Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint5_30Input = {
readonly sessionID: Endpoint5_30Request["params"]["sessionID"]
readonly messageID: Endpoint5_30Request["params"]["messageID"]
}
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -420,23 +425,24 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
switchAgent: Endpoint5_6(raw),
switchModel: Endpoint5_7(raw),
rename: Endpoint5_8(raw),
move: Endpoint5_9(raw),
prompt: Endpoint5_10(raw),
command: Endpoint5_11(raw),
skill: Endpoint5_12(raw),
synthetic: Endpoint5_13(raw),
shell: Endpoint5_14(raw),
compact: Endpoint5_15(raw),
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
interrupt: Endpoint5_27(raw),
background: Endpoint5_28(raw),
message: Endpoint5_29(raw),
archive: Endpoint5_9(raw),
move: Endpoint5_10(raw),
prompt: Endpoint5_11(raw),
command: Endpoint5_12(raw),
skill: Endpoint5_13(raw),
synthetic: Endpoint5_14(raw),
shell: Endpoint5_15(raw),
compact: Endpoint5_16(raw),
wait: Endpoint5_17(raw),
revert: { stage: Endpoint5_18(raw), clear: Endpoint5_19(raw), commit: Endpoint5_20(raw) },
context: Endpoint5_21(raw),
pending: { list: Endpoint5_22(raw) },
instructions: { entry: { list: Endpoint5_23(raw), put: Endpoint5_24(raw), remove: Endpoint5_25(raw) } },
generate: Endpoint5_26(raw),
log: Endpoint5_27(raw),
interrupt: Endpoint5_28(raw),
background: Endpoint5_29(raw),
message: Endpoint5_30(raw),
})
type Endpoint6_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@@ -736,17 +742,30 @@ const adaptGroup12 = (raw: RawClient["server.credential"]) => ({ update: Endpoin
const Endpoint13_0 = (raw: RawClient["server.project"]) => () =>
raw["project.list"]({}).pipe(Effect.mapError(mapClientError))
type Endpoint13_1Request = Parameters<RawClient["server.project"]["project.current"]>[0]
type Endpoint13_1Input = { readonly location?: Endpoint13_1Request["query"]["location"] }
const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1Input) =>
type Endpoint13_1Request = Parameters<RawClient["server.project"]["project.update"]>[0]
type Endpoint13_1Input = {
readonly projectID: Endpoint13_1Request["params"]["projectID"]
readonly name?: Endpoint13_1Request["payload"]["name"]
readonly icon?: Endpoint13_1Request["payload"]["icon"]
readonly commands?: Endpoint13_1Request["payload"]["commands"]
}
const Endpoint13_1 = (raw: RawClient["server.project"]) => (input: Endpoint13_1Input) =>
raw["project.update"]({
params: { projectID: input["projectID"] },
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint13_2Request = Parameters<RawClient["server.project"]["project.current"]>[0]
type Endpoint13_2Input = { readonly location?: Endpoint13_2Request["query"]["location"] }
const Endpoint13_2 = (raw: RawClient["server.project"]) => (input?: Endpoint13_2Input) =>
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint13_2Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
type Endpoint13_2Input = {
readonly projectID: Endpoint13_2Request["params"]["projectID"]
readonly location?: Endpoint13_2Request["query"]["location"]
type Endpoint13_3Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
type Endpoint13_3Input = {
readonly projectID: Endpoint13_3Request["params"]["projectID"]
readonly location?: Endpoint13_3Request["query"]["location"]
}
const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2Input) =>
const Endpoint13_3 = (raw: RawClient["server.project"]) => (input: Endpoint13_3Input) =>
raw["project.directories"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
@@ -754,8 +773,9 @@ const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2I
const adaptGroup13 = (raw: RawClient["server.project"]) => ({
list: Endpoint13_0(raw),
current: Endpoint13_1(raw),
directories: Endpoint13_2(raw),
update: Endpoint13_1(raw),
current: Endpoint13_2(raw),
directories: Endpoint13_3(raw),
})
type Endpoint14_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
@@ -977,21 +997,26 @@ const Endpoint19_0 = (raw: RawClient["server.event"]) => () =>
const adaptGroup19 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint19_0(raw) })
type Endpoint20_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint20_0Request = Parameters<RawClient["server.pty"]["pty.shells"]>[0]
type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
const Endpoint20_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint20_0Input) =>
raw["pty.shells"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint20_1Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint20_1Input = { readonly location?: Endpoint20_1Request["query"]["location"] }
const Endpoint20_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint20_1Input) =>
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint20_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint20_1Input = {
readonly location?: Endpoint20_1Request["query"]["location"]
readonly command?: Endpoint20_1Request["payload"]["command"]
readonly args?: Endpoint20_1Request["payload"]["args"]
readonly cwd?: Endpoint20_1Request["payload"]["cwd"]
readonly title?: Endpoint20_1Request["payload"]["title"]
readonly env?: Endpoint20_1Request["payload"]["env"]
type Endpoint20_2Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint20_2Input = {
readonly location?: Endpoint20_2Request["query"]["location"]
readonly command?: Endpoint20_2Request["payload"]["command"]
readonly args?: Endpoint20_2Request["payload"]["args"]
readonly cwd?: Endpoint20_2Request["payload"]["cwd"]
readonly title?: Endpoint20_2Request["payload"]["title"]
readonly env?: Endpoint20_2Request["payload"]["env"]
}
const Endpoint20_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint20_1Input) =>
const Endpoint20_2 = (raw: RawClient["server.pty"]) => (input?: Endpoint20_2Input) =>
raw["pty.create"]({
query: { location: input?.["location"] },
payload: {
@@ -1003,46 +1028,61 @@ const Endpoint20_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint20_1Inpu
},
}).pipe(Effect.mapError(mapClientError))
type Endpoint20_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint20_2Input = {
readonly ptyID: Endpoint20_2Request["params"]["ptyID"]
readonly location?: Endpoint20_2Request["query"]["location"]
type Endpoint20_3Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint20_3Input = {
readonly ptyID: Endpoint20_3Request["params"]["ptyID"]
readonly location?: Endpoint20_3Request["query"]["location"]
}
const Endpoint20_2 = (raw: RawClient["server.pty"]) => (input: Endpoint20_2Input) =>
const Endpoint20_3 = (raw: RawClient["server.pty"]) => (input: Endpoint20_3Input) =>
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint20_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint20_3Input = {
readonly ptyID: Endpoint20_3Request["params"]["ptyID"]
readonly location?: Endpoint20_3Request["query"]["location"]
readonly title?: Endpoint20_3Request["payload"]["title"]
readonly size?: Endpoint20_3Request["payload"]["size"]
type Endpoint20_4Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint20_4Input = {
readonly ptyID: Endpoint20_4Request["params"]["ptyID"]
readonly location?: Endpoint20_4Request["query"]["location"]
readonly title?: Endpoint20_4Request["payload"]["title"]
readonly size?: Endpoint20_4Request["payload"]["size"]
}
const Endpoint20_3 = (raw: RawClient["server.pty"]) => (input: Endpoint20_3Input) =>
const Endpoint20_4 = (raw: RawClient["server.pty"]) => (input: Endpoint20_4Input) =>
raw["pty.update"]({
params: { ptyID: input["ptyID"] },
query: { location: input["location"] },
payload: { title: input["title"], size: input["size"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint20_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint20_4Input = {
readonly ptyID: Endpoint20_4Request["params"]["ptyID"]
readonly location?: Endpoint20_4Request["query"]["location"]
type Endpoint20_5Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint20_5Input = {
readonly ptyID: Endpoint20_5Request["params"]["ptyID"]
readonly location?: Endpoint20_5Request["query"]["location"]
}
const Endpoint20_4 = (raw: RawClient["server.pty"]) => (input: Endpoint20_4Input) =>
const Endpoint20_5 = (raw: RawClient["server.pty"]) => (input: Endpoint20_5Input) =>
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint20_6Request = Parameters<RawClient["server.pty"]["pty.connectToken"]>[0]
type Endpoint20_6Input = {
readonly ptyID: Endpoint20_6Request["params"]["ptyID"]
readonly location?: Endpoint20_6Request["query"]["location"]
readonly "x-opencode-ticket"?: Endpoint20_6Request["headers"]["x-opencode-ticket"]
}
const Endpoint20_6 = (raw: RawClient["server.pty"]) => (input: Endpoint20_6Input) =>
raw["pty.connectToken"]({
params: { ptyID: input["ptyID"] },
query: { location: input["location"] },
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup20 = (raw: RawClient["server.pty"]) => ({
list: Endpoint20_0(raw),
create: Endpoint20_1(raw),
get: Endpoint20_2(raw),
update: Endpoint20_3(raw),
remove: Endpoint20_4(raw),
shells: Endpoint20_0(raw),
list: Endpoint20_1(raw),
create: Endpoint20_2(raw),
get: Endpoint20_3(raw),
update: Endpoint20_4(raw),
remove: Endpoint20_5(raw),
connectToken: Endpoint20_6(raw),
})
type Endpoint21_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
@@ -1214,34 +1254,50 @@ const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
refresh: Endpoint24_2(raw),
})
type Endpoint25_0Request = Parameters<RawClient["server.vcs"]["vcs.status"]>[0]
type Endpoint25_0Request = Parameters<RawClient["server.vcs"]["vcs.get"]>[0]
type Endpoint25_0Input = { readonly location?: Endpoint25_0Request["query"]["location"] }
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint25_1Request = Parameters<RawClient["server.vcs"]["vcs.status"]>[0]
type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] }
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint25_1Request = Parameters<RawClient["server.vcs"]["vcs.diff"]>[0]
type Endpoint25_1Input = {
readonly location?: Endpoint25_1Request["query"]["location"]
readonly mode: Endpoint25_1Request["query"]["mode"]
readonly context?: Endpoint25_1Request["query"]["context"]
type Endpoint25_2Request = Parameters<RawClient["server.vcs"]["vcs.diff"]>[0]
type Endpoint25_2Input = {
readonly location?: Endpoint25_2Request["query"]["location"]
readonly mode: Endpoint25_2Request["query"]["mode"]
readonly context?: Endpoint25_2Request["query"]["context"]
}
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) =>
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint25_0(raw),
status: Endpoint25_1(raw),
diff: Endpoint25_2(raw),
})
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
type Endpoint26_0Request = Parameters<RawClient["server.path"]["path.get"]>[0]
type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
const Endpoint26_0 = (raw: RawClient["server.path"]) => (input?: Endpoint26_0Input) =>
raw["path.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup26 = (raw: RawClient["server.path"]) => ({ get: Endpoint26_0(raw) })
const Endpoint27_0 = (raw: RawClient["server.debug"]) => () =>
raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))
type Endpoint26_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
type Endpoint26_1Input = { readonly location?: Endpoint26_1Request["query"]["location"] }
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
type Endpoint27_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
const Endpoint27_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint27_1Input) =>
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
const adaptGroup27 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint27_0(raw), evict: Endpoint27_1(raw) },
})
const adaptClient = (raw: RawClient) => ({
@@ -1271,7 +1327,8 @@ const adaptClient = (raw: RawClient) => ({
reference: adaptGroup23(raw["server.reference"]),
projectCopy: adaptGroup24(raw["server.projectCopy"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
path: adaptGroup26(raw["server.path"]),
debug: adaptGroup27(raw["server.debug"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
+9
View File
@@ -3,13 +3,22 @@ type Client = ReturnType<typeof import("./generated/client.js").make>
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type EventApi = Client["event"]
export type FileApi = Client["file"]
export type IntegrationApi = Client["integration"]
export type MessageApi = Client["message"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type PermissionApi = Client["permission"]
export type PathApi = Client["path"]
export type ProjectApi = Client["project"]
export type ProviderApi = Client["provider"]
export type QuestionApi = Client["question"]
export type ReferenceApi = Client["reference"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
export type PtyApi = Client["pty"]
export type VcsApi = Client["vcs"]
export interface CatalogApi {
readonly provider: ProviderApi
@@ -26,6 +26,8 @@ import type {
SessionSwitchModelOutput,
SessionRenameInput,
SessionRenameOutput,
SessionArchiveInput,
SessionArchiveOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -119,6 +121,8 @@ import type {
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectDirectoriesInput,
@@ -162,6 +166,8 @@ import type {
SkillListInput,
SkillListOutput,
EventSubscribeOutput,
PtyShellsInput,
PtyShellsOutput,
PtyListInput,
PtyListOutput,
PtyCreateInput,
@@ -172,6 +178,8 @@ import type {
PtyUpdateOutput,
PtyRemoveInput,
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -200,10 +208,14 @@ import type {
ProjectCopyRemoveOutput,
ProjectCopyRefreshInput,
ProjectCopyRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsDiffInput,
VcsDiffOutput,
PathGetInput,
PathGetOutput,
DebugLocationListOutput,
DebugLocationEvictInput,
DebugLocationEvictOutput,
@@ -535,6 +547,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
archive: (input: SessionArchiveInput, requestOptions?: RequestOptions) =>
request<SessionArchiveOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/archive`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
move: (input: SessionMoveInput, requestOptions?: RequestOptions) =>
request<SessionMoveOutput>(
{
@@ -1149,6 +1172,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>(
{
@@ -1428,6 +1463,18 @@ export function make(options: ClientOptions) {
),
},
pty: {
shells: (input?: PtyShellsInput, requestOptions?: RequestOptions) =>
request<PtyShellsOutput>(
{
method: "GET",
path: `/api/pty/shells`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
list: (input?: PtyListInput, requestOptions?: RequestOptions) =>
request<PtyListOutput>(
{
@@ -1496,6 +1543,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
connectToken: (input: PtyConnectTokenInput, requestOptions?: RequestOptions) =>
request<PtyConnectTokenOutput>(
{
method: "POST",
path: `/api/pty/${encodeURIComponent(input.ptyID)}/connect-token`,
query: { location: input["location"] },
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
successStatus: 200,
declaredStatuses: [403, 404, 401, 400],
empty: false,
},
requestOptions,
),
},
shell: {
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
@@ -1683,6 +1743,18 @@ export function make(options: ClientOptions) {
),
},
vcs: {
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
{
method: "GET",
path: `/api/vcs`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
request<VcsStatusOutput>(
{
@@ -1708,6 +1780,20 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
path: {
get: (input?: PathGetInput, requestOptions?: RequestOptions) =>
request<PathGetOutput>(
{
method: "GET",
path: `/api/path`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
debug: {
location: {
list: (requestOptions?: RequestOptions) =>
+130 -39
View File
@@ -98,6 +98,8 @@ export type SessionMessageShell = {
output?: { output: string; cursor: number; size: number; truncated: boolean }
}
export type SessionMessageAssistantText = { type: "text"; text: string }
export type SessionMessageProviderState = { [x: string]: JsonValue }
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
@@ -149,6 +151,8 @@ export type ShellInfo = {
time: { started: number; completed?: number }
}
export type SessionMessageProviderState3 = { [x: string]: any }
export type SessionMessageProviderState4 = { [x: string]: any }
export type SessionMessageProviderState5 = { [x: string]: any }
@@ -157,12 +161,6 @@ export type SessionMessageProviderState6 = { [x: string]: any }
export type SessionMessageProviderState7 = { [x: string]: any }
export type SessionMessageProviderState8 = { [x: string]: any }
export type SessionMessageProviderState9 = { [x: string]: any }
export type SessionMessageProviderState10 = { [x: string]: any }
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
@@ -503,6 +501,10 @@ export type QuestionTool = { messageID: string; callID: string }
export type QuestionAnswer = Array<string>
export type PtyShell = { path: string; name: string; acceptable: boolean }
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
@@ -528,6 +530,8 @@ export type ReferenceGitSource = {
export type ProjectCopyCopy = { directory: string }
export type VcsInfo = { branch?: string; defaultBranch?: string }
export type VcsFileStatus = {
file: string
additions: number
@@ -535,6 +539,8 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified"
}
export type PathInfo = { home: string; state: string; config: string; worktree: string; directory: string }
export type SessionMessageModelSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -551,6 +557,7 @@ export type CommandInfo = {
agent?: string
model?: ModelRef
subtask?: boolean
source?: "command" | "mcp"
}
export type ProviderRequest = {
@@ -601,6 +608,16 @@ export type SessionRenamed = {
data: { sessionID: string; title: string }
}
export type SessionArchived = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.archived"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string }
}
export type SessionDeleted = {
id: string
created: number
@@ -719,6 +736,26 @@ export type SessionStepEnded = {
}
}
export type SessionTextStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number }
}
export type SessionTextEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string }
}
export type SessionToolInputStarted = {
id: string
created: number
@@ -1216,8 +1253,6 @@ export type SessionPendingSynthetic = {
delivery: "steer" | "queue"
}
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
export type SessionMessageAssistantReasoning = {
type: "reasoning"
text: string
@@ -1328,32 +1363,6 @@ export type ShellCreated = {
data: { info: ShellInfo }
}
export type SessionTextStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState4 }
}
export type SessionTextEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
ordinal: number
text: string
state?: SessionMessageProviderState5
}
}
export type SessionReasoningStarted = {
id: string
created: number
@@ -1361,7 +1370,7 @@ export type SessionReasoningStarted = {
type: "session.reasoning.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState6 }
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState3 }
}
export type SessionReasoningEnded = {
@@ -1376,7 +1385,7 @@ export type SessionReasoningEnded = {
assistantMessageID: string
ordinal: number
text: string
state?: SessionMessageProviderState7
state?: SessionMessageProviderState4
}
}
@@ -1393,7 +1402,7 @@ export type SessionToolCalled = {
callID: string
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState8
state?: SessionMessageProviderState5
}
}
@@ -1858,7 +1867,7 @@ export type SessionToolSuccess = {
content: Array<LLMToolContent>
result?: any
executed: boolean
resultState?: SessionMessageProviderState9
resultState?: SessionMessageProviderState6
}
}
@@ -1878,7 +1887,7 @@ export type SessionToolFailed = {
metadata?: { [x: string]: any }
result?: any
executed: boolean
resultState?: SessionMessageProviderState10
resultState?: SessionMessageProviderState7
}
}
@@ -2212,6 +2221,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionArchived
| SessionDeleted
| SessionForked
| SessionInputPromoted
@@ -2306,6 +2316,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionArchived
| SessionUsageUpdated
| SessionDeleted
| SessionForked
@@ -2505,6 +2516,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"
@@ -2537,6 +2556,10 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
export type ForbiddenError = { readonly _tag: "ForbiddenError"; readonly message: string }
export const isForbiddenError = (value: unknown): value is ForbiddenError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ForbiddenError"
export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string }
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
@@ -2767,6 +2790,10 @@ export type SessionRenameInput = {
export type SessionRenameOutput = void
export type SessionArchiveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionArchiveOutput = void
export type SessionMoveInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
@@ -3594,6 +3621,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
@@ -4656,6 +4704,17 @@ export type SkillListOutput = {
export type EventSubscribeOutput = V2Event
export type PtyShellsInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtyShellsOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<PtyShell>
}
export type PtyListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -4751,6 +4810,19 @@ export type PtyRemoveInput = {
export type PtyRemoveOutput = void
export type PtyConnectTokenInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly "x-opencode-ticket"?: { readonly "x-opencode-ticket"?: string | undefined }["x-opencode-ticket"]
}
export type PtyConnectTokenOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: PtyTicketConnectToken
}
export type ShellListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -4928,6 +5000,17 @@ export type ProjectCopyRefreshInput = {
export type ProjectCopyRefreshOutput = void
export type VcsGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: VcsInfo
}
export type VcsStatusInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -4962,6 +5045,14 @@ export type VcsDiffOutput = {
data: Array<FileDiffInfo>
}
export type PathGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PathGetOutput = PathInfo
export type DebugLocationListOutput = Array<LocationRef>
export type DebugLocationEvictInput = {
+9
View File
@@ -4,13 +4,22 @@ export type {
CatalogApi,
CommandApi,
EventApi,
FileApi,
IntegrationApi,
MessageApi,
McpApi,
ModelApi,
PermissionApi,
PathApi,
PluginApi,
ProjectApi,
ProviderApi,
QuestionApi,
ReferenceApi,
SessionApi,
SkillApi,
PtyApi,
VcsApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
-3
View File
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -65,7 +64,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const registration = await registered(options.file, true)
if (registration.service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
@@ -82,7 +80,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (failure !== undefined) throw failure
const finished = [...contenders].filter(contenderFinished)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
+33 -3
View File
@@ -31,6 +31,7 @@ test("exposes every standard HTTP API group", () => {
"reference",
"projectCopy",
"vcs",
"path",
"debug",
])
expect(Object.keys(client.debug)).toEqual(["location"])
@@ -42,10 +43,37 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
expect(Object.keys(client.pty)).toEqual(["shells", "list", "create", "get", "update", "remove", "connectToken"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current", "directories"])
})
test("PTY connect token sends the required browser header", 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 Response.json({
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
data: { ticket: "ticket", expires_in: 60 },
})
},
})
const result = await client.pty.connectToken({
ptyID: "pty_test",
location: { directory: "/tmp/project" },
"x-opencode-ticket": "1",
})
expect(result.data.ticket).toBe("ticket")
expect(request?.method).toBe("POST")
expect(request?.headers.get("x-opencode-ticket")).toBe("1")
expect(request?.url).toBe(
"http://localhost:3000/api/pty/pty_test/connect-token?location%5Bdirectory%5D=%2Ftmp%2Fproject",
)
})
test("server.get uses the public HTTP contract", async () => {
@@ -399,6 +427,7 @@ test("session methods use the public HTTP contract", async () => {
sessionID: "ses_test",
model: { id: "claude", providerID: "anthropic" },
})
await client.session.archive({ sessionID: "ses_test" })
const admitted = await client.session.prompt({
sessionID: "ses_test",
text: "Hello",
@@ -434,6 +463,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/archive"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
["POST", "http://localhost:3000/api/session/ses_test/generate"],
["POST", "http://localhost:3000/api/session/ses_test/synthetic"],
+5 -1
View File
@@ -85,6 +85,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
name: mcpCommandName(prompt.server, prompt.name),
template: "",
description: prompt.description,
source: "mcp",
}),
)
})
@@ -98,7 +99,10 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return (yield* mcpCommands()).find((command) => command.name === name)
}),
list: Effect.fn("CommandV2.list")(function* () {
const commands = Array.from(state.get().commands.values()) as Info[]
const commands = Array.from(state.get().commands.values()).map((command) => ({
...command,
source: command.source ?? "command" as const,
}))
const names = new Set(commands.map((command) => command.name))
return [
...commands,
+30 -50
View File
@@ -2,12 +2,10 @@ export * as Generate from "./generate"
import { LLM, LLMClient, LLMError } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "./catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform"
import { Integration } from "./integration"
import { ModelResolver } from "./model-resolver"
import { ModelV2 } from "./model"
import { SessionRunnerModel } from "./session/runner/model"
export interface TextInput {
readonly prompt: string
@@ -19,10 +17,10 @@ export class ModelSelectionError extends Schema.TaggedErrorClass<ModelSelectionE
{ message: Schema.String },
) {}
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()(
"Generate.UnavailableError",
{ message: Schema.String, service: Schema.optional(Schema.String) },
) {}
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()("Generate.UnavailableError", {
message: Schema.String,
service: Schema.optional(Schema.String),
}) {}
export type Error = ModelSelectionError | UnavailableError
@@ -35,56 +33,34 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const llm = yield* LLMClient.Service
const selectModel = Effect.fn("Generate.selectModel")(function* (requested?: ModelV2.Ref) {
const selected = requested
? yield* catalog.model.get(requested.providerID, requested.id)
: yield* catalog.model.default().pipe(
Effect.flatMap((model) =>
model && SessionRunnerModel.supported(model)
? Effect.succeed(model)
: Effect.map(catalog.model.available(), (models) => models.find(SessionRunnerModel.supported)),
),
)
if (!selected)
return yield* new ModelSelectionError({
message: requested
? `Model unavailable: ${requested.providerID}/${requested.id}`
: "No model specified and no supported model is available",
})
return yield* SessionRunnerModel.withVariant(selected, requested?.variant).pipe(
Effect.mapError(
() =>
new ModelSelectionError({
message: `Variant unavailable for ${selected.providerID}/${selected.id}: ${requested?.variant}`,
}),
),
)
})
const resolver = yield* ModelResolver.Service
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
const selected = yield* selectModel(input.model)
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
const resolved = yield* resolver.resolve(input.model).pipe(
Effect.catchTags({
"SessionRunnerModel.VariantUnavailableError": (error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
"SessionRunnerModel.UnsupportedPackageError": (error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
}),
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const model = yield* SessionRunnerModel.fromCatalogModel(selected, credential).pipe(
Effect.mapError((error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: selected.providerID }),
),
)
const response = yield* llm.generate(LLM.request({ model, prompt: input.prompt })).pipe(
if (!resolved)
return yield* new ModelSelectionError({
message: input.model
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: LLMError) =>
new UnavailableError({
message: error.message,
service: selected.providerID,
service: resolved.ref.providerID,
}),
),
)
@@ -106,4 +82,8 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node, llmClient] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [ModelResolver.node, llmClient],
})
+2
View File
@@ -20,6 +20,7 @@ import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { ModelResolver } from "./model-resolver"
import { MCP } from "./mcp/index"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
@@ -58,6 +59,7 @@ const locationServiceNodes = [
Reference.node,
Integration.node,
Catalog.node,
ModelResolver.node,
AISDK.node,
PluginV2.node,
PluginSupervisor.node,
+344
View File
@@ -0,0 +1,344 @@
export * as ModelResolver from "./model-resolver"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { Credential } from "./credential"
import { Integration } from "./integration"
import { ModelV2 } from "./model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "./plugin/provider/openai-codex"
import { ProviderV2 } from "./provider"
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {
override get message() {
return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}`
}
}
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
"SessionRunnerModel.UnsupportedPackageError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
package: Schema.String,
},
) {
override get message() {
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
}
}
export type Error = VariantUnavailableError | UnsupportedPackageError | Integration.AuthorizationError
export interface Resolved {
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
export interface Interface {
readonly resolve: (requested?: ModelV2.Ref) => Effect.Effect<Resolved | undefined, Error>
readonly resolveModel: (model: ModelV2.Info, variant?: ModelV2.VariantID) => Effect.Effect<Resolved, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ModelResolver") {}
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return undefined
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: ModelV2.Info) => {
const packageName = ProviderV2.packageName(model.package)
const generated = new Map<string, string>()
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
generated.set("OpenAI-Organization", model.settings.organization)
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
generated.set("OpenAI-Project", model.settings.project)
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (
model: ModelV2.Info,
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
const packageName = ProviderV2.packageName(model.package)
if (packageName === "@ai-sdk/openai") return { openai: settings }
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
return undefined
}
export const withVariant = (
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
const id = variantID === "default" ? undefined : variantID
const variant = model.variants?.find((item) => item.id === id)
if (!variant && variantID !== undefined && variantID !== "default")
return Effect.fail(
new VariantUnavailableError({
providerID: model.providerID,
modelID: model.id,
variant: variantID,
}),
)
return Effect.succeed(
variant
? produce(model, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
})
: model,
)
}
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
}
export const fromCatalogModel = (
model: ModelV2.Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<Model, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
})
const packageName = ProviderV2.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
ProviderV2.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package)) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
...credential?.metadata,
})
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!resolved.package) return Effect.fail(unsupported(resolved))
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const configured = { ...resolved.settings, ...credential?.metadata }
const settings = {
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return Model.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
if (
specifier === "@opencode-ai/ai/providers/anthropic" ||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
)
return { authToken: credential.access }
if (
specifier === "@opencode-ai/ai/providers/google-vertex" ||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
)
return { accessToken: credential.access }
return { apiKey: credential.access }
}
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
return rest
}
const codexModel = (
model: ModelV2.Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: ModelV2.Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
export const resolveModel = (
model: ModelV2.Info,
variant: ModelV2.VariantID | undefined,
credential?: Credential.Value,
dependencies?: Dependencies,
) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)))
export const supported = (model: ModelV2.Info) => Boolean(model.package)
/** Resolves catalog selections into runtime models for the current Location. */
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const load = Effect.fn("ModelResolver.resolveModel")(function* (
selected: ModelV2.Info,
variant?: ModelV2.VariantID,
) {
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const model = yield* resolveModel(
selected,
variant,
connection ? yield* integrations.connection.resolve(connection) : undefined,
{
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
},
)
return {
model,
ref: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(variant === undefined ? {} : { variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
})
return Service.of({
resolve: Effect.fn("ModelResolver.resolve")(function* (requested) {
const selected = requested
? yield* catalog.model.get(requested.providerID, requested.id)
: yield* catalog.model
.default()
.pipe(
Effect.flatMap((model) =>
model && supported(model)
? Effect.succeed(model)
: Effect.map(catalog.model.available(), (models) => models.find(supported)),
),
)
if (!selected) return undefined
return yield* load(selected, requested?.variant)
}),
resolveModel: load,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
})
@@ -1,7 +1,7 @@
export * as OpenAICodex from "./openai-codex"
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
// Codex routing lives in ModelResolver and catalog filtering.
// in OpenAIPlugin, sharing this module. Once the native provider packages land
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
+29 -2
View File
@@ -3,7 +3,7 @@ export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema"
import { Database } from "./database/database"
@@ -37,6 +37,13 @@ export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = ProjectSchema.Directories
export type Directories = typeof Directories.Type
export const UpdateInput = ProjectSchema.UpdateInput
export type UpdateInput = typeof UpdateInput.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Project.NotFoundError", {
projectID: ID,
}) {}
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -58,6 +65,7 @@ export const root = Effect.fn("Project.root")(function* (
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly update: (projectID: ID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
/**
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
@@ -121,6 +129,25 @@ const layer = Layer.effect(
return yield* projectDirectories.list(input.projectID)
})
const update = Effect.fn("Project.update")(function* (projectID: ID, input: UpdateInput) {
const row = yield* db
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_url_override: input.icon?.override,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, projectID))
.returning()
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ projectID })
return fromRow(row)
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -229,7 +256,7 @@ const layer = Layer.effect(
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
})
return Service.of({ list, directories, resolve, commit })
return Service.of({ list, directories, update, resolve, commit })
}),
)
+3
View File
@@ -22,6 +22,9 @@ export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = Project.Directories
export type Directories = typeof Directories.Type
export const UpdateInput = Project.UpdateInput
export type UpdateInput = typeof UpdateInput.Type
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
+6 -1
View File
@@ -78,6 +78,7 @@ export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.Exi
}) {}
export interface Interface {
readonly shells: () => Effect.Effect<ReadonlyArray<Pty.Shell>>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<Info>
@@ -158,6 +159,10 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return Array.from(sessions.values()).map((session) => session.info)
})
const shells = Effect.fn("Pty.shells")(function* () {
return yield* Effect.promise(() => ShellSelect.list(options))
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
return (yield* requireSession(id)).info
})
@@ -309,7 +314,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
}
})
return Service.of({ list, get, create, update, remove, write, attach })
return Service.of({ shells, list, get, create, update, remove, write, attach })
}),
)
+6
View File
@@ -226,6 +226,7 @@ export interface Interface {
model: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly archive: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly move: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
@@ -705,6 +706,11 @@ const layer = Layer.effect(
title: input.title,
})
}),
archive: Effect.fn("V2Session.archive")(function* (sessionID) {
const session = yield* result.get(sessionID)
if (session.time.archived) return
yield* events.publish(SessionEvent.Archived, { sessionID })
}),
move: Effect.fn("V2Session.move")(function* (input) {
const current = yield* result.get(input.sessionID)
const value = input.directory.trim()
+1 -1
View File
@@ -53,7 +53,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
archived: row.time_archived !== null ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
}
+3 -7
View File
@@ -171,6 +171,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.moved": () => Effect.void,
"session.renamed": () => Effect.void,
"session.archived": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
@@ -307,9 +308,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(SessionMessage.AssistantText.make({ type: "text", text: "", state: event.data.state })),
)
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
})
},
"session.text.delta": (event) => {
@@ -321,10 +320,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft)
if (match) {
match.text = event.data.text
if (event.data.state !== undefined) match.state = event.data.state
}
if (match) match.text = event.data.text
})
},
"session.tool.input.started": (event) => {
+11
View File
@@ -609,6 +609,17 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Archived, (event) =>
db
.update(SessionTable)
.set({
time_archived: DateTime.toEpochMillis(event.created),
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* events.project(SessionEvent.InputPromoted, (event) =>
+21 -323
View File
@@ -2,30 +2,16 @@ export * as SessionRunnerModel from "./model"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "../../aisdk"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ModelResolver } from "../../model-resolver"
import { ModelV2 } from "../../model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "../../plugin/provider/openai-codex"
import { ProviderV2 } from "../../provider"
import { SessionSchema } from "../schema"
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
"SessionRunnerModel.ModelNotSelectedError",
{
sessionID: SessionSchema.ID,
},
{ sessionID: SessionSchema.ID },
) {
override get message() {
return `No model is available for session ${this.sessionID}`
@@ -34,59 +20,19 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
},
{ providerID: ProviderV2.ID, modelID: ModelV2.ID },
) {
override get message() {
return `Model unavailable: ${this.providerID}/${this.modelID}`
}
}
export const VariantUnavailableError = ModelResolver.VariantUnavailableError
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {
override get message() {
return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}`
}
}
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
"SessionRunnerModel.UnsupportedPackageError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
package: Schema.String,
},
) {
override get message() {
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
}
}
export type Error =
| ModelNotSelectedError
| ModelUnavailableError
| VariantUnavailableError
| UnsupportedPackageError
| Integration.AuthorizationError
export interface Resolved {
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
export type Resolved = ModelResolver.Resolved
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
@@ -94,9 +40,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (
model: Model,
@@ -116,276 +59,31 @@ export const resolved = (
cost: options.cost,
})
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: ModelV2.Info) => {
const packageName = ProviderV2.packageName(model.package)
const generated = new Map<string, string>()
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
generated.set("OpenAI-Organization", model.settings.organization)
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
generated.set("OpenAI-Project", model.settings.project)
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (
model: ModelV2.Info,
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
const packageName = ProviderV2.packageName(model.package)
if (packageName === "@ai-sdk/openai") return { openai: settings }
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
}
export const withVariant = (
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
const id = variantID === "default" ? undefined : variantID
const variant = model.variants?.find((item) => item.id === id)
if (!variant && variantID !== undefined && variantID !== "default")
return Effect.fail(
new VariantUnavailableError({
providerID: model.providerID,
modelID: model.id,
variant: variantID,
}),
)
return Effect.succeed(
variant
? produce(model, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
})
: model,
)
}
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
}
export const fromCatalogModel = (
model: ModelV2.Info,
credential?: Credential.Value,
dependencies: Dependencies = {},
): Effect.Effect<Model, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
})
const packageName = ProviderV2.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
ProviderV2.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package)) {
if (!dependencies.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
...credential?.metadata,
})
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!resolved.package) return Effect.fail(unsupported(resolved))
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const configured = { ...resolved.settings, ...credential?.metadata }
const settings = {
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return Model.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? { ...runtime.compatibility, ...resolved.compatibility }
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
if (
specifier === "@opencode-ai/ai/providers/anthropic" ||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
)
return { authToken: credential.access }
if (
specifier === "@opencode-ai/ai/providers/google-vertex" ||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
)
return { accessToken: credential.access }
return { apiKey: credential.access }
}
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
return rest
}
const codexModel = (
model: ModelV2.Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: ModelV2.Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
export const resolve = (
session: SessionSchema.Info,
model: ModelV2.Info,
credential?: Credential.Value,
dependencies?: Dependencies,
) =>
withVariant(model, session.model?.variant).pipe(
Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)),
)
export const supported = (model: ModelV2.Info) => Boolean(model.package)
/** Resolves models from the catalog belonging to the current Location runtime. */
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const resolver = yield* ModelResolver.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
const defaultModel = session.model ? undefined : yield* catalog.model.default()
const selected = session.model
? (yield* catalog.model.available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
: defaultModel && supported(defaultModel)
? defaultModel
: (yield* catalog.model.available()).find(supported)
if (!selected && session.model)
if (!session.model) {
const resolved = yield* resolver.resolve()
if (resolved) return resolved
return yield* new ModelNotSelectedError({ sessionID: session.id })
}
const selected = (yield* catalog.model.available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
if (!selected)
return yield* new ModelUnavailableError({
providerID: session.model.providerID,
modelID: session.model.id,
})
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const model = yield* resolve(
session,
selected,
connection ? yield* integrations.connection.resolve(connection) : undefined,
{
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
},
)
return {
model,
ref: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
return yield* resolver.resolveModel(selected, session.model.variant)
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
})
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, ModelResolver.node] })
@@ -142,14 +142,13 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const text = fragments(
"text",
(_textID, value, ordinal, state) =>
(_textID, value, ordinal) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
text: value,
state,
})
}),
true,
@@ -318,16 +317,15 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return
case "text-start":
retryEvidence = true
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
const startedTextOrdinal = yield* text.start(event.id)
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
ordinal: startedTextOrdinal,
state: providerState(event.providerMetadata),
})
return
case "text-delta":
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
const deltaTextOrdinal = yield* text.append(event.id, event.text)
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
@@ -336,7 +334,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
})
return
case "text-end":
yield* text.end(event.id, providerState(event.providerMetadata))
yield* text.end(event.id)
return
case "reasoning-start":
retryEvidence = true
@@ -123,14 +123,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text")
return [
{
type: "text",
text: item.text,
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
},
]
if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "reasoning")
return reuseProviderMetadata
? [
+5 -2
View File
@@ -4,7 +4,10 @@ import { Schema, SchemaGetter } from "effect"
import { PositiveInt } from "../../schema"
import { ConfigPermissionV1 } from "./permission"
const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
const AgentSchema = Schema.StructWithRest(
Schema.Struct({
@@ -26,7 +29,7 @@ const AgentSchema = Schema.StructWithRest(
}),
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
color: Schema.optional(Color).annotate({
description: "Hex color code (e.g., #FF5733)",
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
}),
steps: Schema.optional(PositiveInt).annotate({
description: "Maximum number of agentic iterations before forcing text-only response",
+1 -1
View File
@@ -161,7 +161,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
+7 -2
View File
@@ -2,7 +2,7 @@ export * as Vcs from "./vcs"
import { Context, Effect, Layer } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
@@ -10,13 +10,14 @@ import { AppProcess } from "@opencode-ai/util/process"
import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg"
export { FileStatus, Mode }
export { FileStatus, Info, Mode }
export interface DiffOptions {
readonly context?: number
}
export interface Interface {
readonly info: () => Effect.Effect<Info>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
}
@@ -40,6 +41,10 @@ const layer = Layer.effect(
const location = yield* Location.Service
const impl = adapter(proc, fs, location)
return Service.of({
info: Effect.fn("Vcs.info")(function* () {
if (!impl) return {}
return yield* impl.info()
}),
status: Effect.fn("Vcs.status")(function* () {
if (!impl) return []
return yield* impl.status()
+6
View File
@@ -20,6 +20,12 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
return {
info: Effect.fn("VcsGit.info")(function* () {
const [branch, root] = yield* Effect.all([ctx.git.branch(ctx.directory), ctx.git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
return { branch, defaultBranch: root?.name }
}),
status: Effect.fn("VcsGit.status")(function* () {
const git = ctx.git
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
+3
View File
@@ -73,6 +73,9 @@ export function make(
})
return {
info: Effect.fn("VcsHg.info")(function* () {
return { branch: yield* hg.branch(), defaultBranch: "default" }
}),
status: Effect.fn("VcsHg.status")(function* () {
const [items, batch] = yield* Effect.all(
// Zero-context patches are enough to count changed lines.
+1
View File
@@ -59,6 +59,7 @@ describe("CommandV2", () => {
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
},
source: "command",
}),
])
}),
+3 -2
View File
@@ -95,9 +95,10 @@ Review files`,
variant: ModelV2.VariantID.make("high"),
},
subtask: true,
source: "command",
}),
CommandV2.Info.make({ name: "empty", template: "" }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
CommandV2.Info.make({ name: "empty", template: "", source: "command" }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs", source: "command" }),
])
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
@@ -79,6 +79,7 @@ describe("node build", () => {
return Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
update: () => Effect.die("unused"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
commit: () => Effect.void,
})
+111
View File
@@ -0,0 +1,111 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Npm } from "@opencode-ai/util/npm"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const selected = ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("gemini")),
package: ProviderV2.aisdk("@ai-sdk/google"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
const catalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
},
model: {
get: () => Effect.succeed(selected),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
default: () => Effect.die("unused"),
small: () => Effect.die("unused"),
},
})
const integrations = Layer.mock(Integration.Service, {
connection: {
active: () => Effect.succeed(undefined),
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
complete: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
command: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
})
const npm = Layer.mock(Npm.Service, {
add: () => Effect.die("unused"),
install: () => Effect.die("unused"),
which: () => Effect.die("unused"),
})
const aisdk = Layer.mock(AISDK.Service, {
hook: {
sdk: () => Effect.die("unused"),
language: () => Effect.die("unused"),
},
model: () => Effect.succeed(runtime),
})
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: () => Stream.die("unused"),
generate: () =>
Effect.sync(() => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "OK" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.finish({ reason: "stop" }),
])
if (!response) throw new Error("Incomplete generate response")
return response
}),
})
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
const resolverIt = testEffect(resolver)
it.effect("loads dynamic AI SDK models", () =>
Effect.gen(function* () {
const generate = yield* Generate.Service
const result = yield* generate.text({
prompt: "Return exactly OK",
model: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
})
expect(result).toBe("OK")
}),
)
resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const result = yield* resolver.resolve(ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }))
expect(result).toEqual({
model: runtime,
ref: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
capabilities: selected.capabilities,
cost: selected.cost,
})
}),
)
+1
View File
@@ -14,6 +14,7 @@ const projectLayer = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
update: () => Effect.die("unused"),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
@@ -1,17 +1,13 @@
import { describe, expect } from "bun:test"
import { LLM, Model } from "@opencode-ai/ai"
import { LLMClient } from "@opencode-ai/ai/route"
import { DateTime, Effect } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { it } from "./lib/effect"
interface ModelOptions {
@@ -43,13 +39,13 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
limit: { context: 100, output: 20 },
})
describe("SessionRunnerModel", () => {
describe("ModelResolver", () => {
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
})
const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog)
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(catalog.id).toBe(ModelV2.ID.make("test-model"))
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
@@ -68,7 +64,7 @@ describe("SessionRunnerModel", () => {
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
}),
@@ -82,7 +78,7 @@ describe("SessionRunnerModel", () => {
it.effect("treats an empty configured API key as omitted", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
@@ -101,7 +97,7 @@ describe("SessionRunnerModel", () => {
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
compatibility: { reasoningField: "vendor_reasoning" },
settings: {
@@ -130,7 +126,7 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
it.effect("overlays selected OpenAI variant settings and bodies", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
@@ -147,22 +143,7 @@ describe("SessionRunnerModel", () => {
},
],
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("high"),
},
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({
@@ -177,7 +158,7 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected OpenAI-compatible Session variant bodies", () =>
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
settings: { baseURL: "https://compatible.example/v1" },
@@ -190,18 +171,7 @@ describe("SessionRunnerModel", () => {
},
],
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_compatible_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
@@ -211,27 +181,12 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
it.effect("rejects an explicit unavailable variant during model resolution", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant_unavailable"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("unknown"),
},
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip)
const failure = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("unknown")).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.VariantUnavailableError",
@@ -243,7 +198,7 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected Anthropic Session variant settings", () =>
it.effect("overlays selected Anthropic variant settings", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
settings: { baseURL: "https://anthropic.example/v1" },
@@ -256,18 +211,7 @@ describe("SessionRunnerModel", () => {
},
],
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_anthropic_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
@@ -280,7 +224,7 @@ describe("SessionRunnerModel", () => {
it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
settings: { baseURL: "https://anthropic.example/v1" },
}),
@@ -296,7 +240,7 @@ describe("SessionRunnerModel", () => {
it.effect("uses resolved credentials for bearer auth", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -320,7 +264,7 @@ describe("SessionRunnerModel", () => {
it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
headers: {},
@@ -343,7 +287,7 @@ describe("SessionRunnerModel", () => {
it.effect("does not project OAuth account metadata into the request body", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -365,7 +309,7 @@ describe("SessionRunnerModel", () => {
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -400,7 +344,7 @@ describe("SessionRunnerModel", () => {
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://openai.example/v1" },
}),
@@ -429,7 +373,7 @@ describe("SessionRunnerModel", () => {
it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai-compatible", {
settings: { baseURL: "https://compatible.example/v1" },
}),
@@ -450,7 +394,7 @@ describe("SessionRunnerModel", () => {
it.effect("maps legacy OpenAI organization and project settings to headers", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { organization: "org_123", project: "proj_123" },
}),
@@ -465,7 +409,7 @@ describe("SessionRunnerModel", () => {
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -496,7 +440,7 @@ describe("SessionRunnerModel", () => {
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -528,12 +472,12 @@ describe("SessionRunnerModel", () => {
it.effect("loads dynamic native provider packages through the injected package loader", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/custom", {
settings: { region: "test" },
headers: { "x-package": "header" },
@@ -565,7 +509,7 @@ describe("SessionRunnerModel", () => {
it.effect("maps OAuth credentials to native provider auth settings", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
@@ -588,7 +532,7 @@ describe("SessionRunnerModel", () => {
] as const
yield* Effect.forEach(packages, ([specifier, key]) =>
SessionRunnerModel.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
@@ -604,12 +548,12 @@ describe("SessionRunnerModel", () => {
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
settings: { project: "test" },
@@ -644,7 +588,7 @@ describe("SessionRunnerModel", () => {
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
const failure = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example/v1" },
}),
@@ -662,12 +606,12 @@ describe("SessionRunnerModel", () => {
it.effect("drops an empty API key before loading an AISDK package", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
yield* SessionRunnerModel.fromCatalogModel(
yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
}),
@@ -685,9 +629,9 @@ describe("SessionRunnerModel", () => {
it.effect("reports whether a catalog model declares a provider package", () =>
Effect.sync(() => {
expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
expect(SessionRunnerModel.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
expect(SessionRunnerModel.supported(model(undefined))).toBe(false)
expect(ModelResolver.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
expect(ModelResolver.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
expect(ModelResolver.supported(model(undefined))).toBe(false)
}),
)
})
+25
View File
@@ -66,6 +66,31 @@ describe("ProjectV2.list", () => {
)
})
describe("ProjectV2.update", () => {
it.effect("updates project metadata", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* ProjectV2.Service
const id = ProjectV2.ID.make("updated")
yield* db
.insert(ProjectTable)
.values({ id, worktree: abs("/updated"), sandboxes: [], time_created: 1, time_updated: 1 })
.run()
const result = yield* project.update(id, {
name: "Updated",
icon: { color: "#ffffff" },
commands: { start: "bun dev" },
})
expect(result.name).toBe("Updated")
expect(result.icon).toEqual({ color: "#ffffff" })
expect(result.commands).toEqual({ start: "bun dev" })
expect(result.time.updated).toBeGreaterThan(1)
}),
)
})
function remoteID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
}
@@ -90,6 +90,18 @@ const waitForOutput = (output: Queue.Queue<string>, text: string) =>
}),
)
describe("Pty.shells", () => {
it.live("lists available shells", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const shells = yield* pty.shells()
expect(shells.length).toBeGreaterThan(0)
expect(shells.every((shell) => shell.path && shell.name && typeof shell.acceptable === "boolean")).toBe(true)
}),
)
})
describe("pty", () => {
it.live("returns typed not found errors for missing sessions", () =>
Effect.gen(function* () {
+10 -8
View File
@@ -36,6 +36,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
update: () => Effect.die("unused"),
commit: () => Effect.void,
}),
)
@@ -49,14 +50,15 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
})
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
+29
View File
@@ -34,6 +34,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
update: () => Effect.die("unused"),
commit: () => Effect.void,
}),
)
@@ -660,4 +661,32 @@ describe("SessionV2.create", () => {
).toBe("Session.NotFoundError")
}),
)
it.effect("archives a Session through one durable event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
yield* session.archive(created.id)
yield* session.archive(created.id)
expect((yield* session.get(created.id)).time.archived).toBeDefined()
const events = Array.from(yield* logEvents(session, created.id).pipe(Stream.runCollect))
expect(events.map((event) => event.type)).toContain("session.archived")
expect(events.filter((event) => event.type === "session.archived")).toHaveLength(1)
}),
)
it.effect("rejects archiving a missing Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
expect(
yield* session.archive(SessionV2.ID.make("ses_missing_archive")).pipe(
Effect.flip,
Effect.map((error) => error._tag),
),
).toBe("Session.NotFoundError")
}),
)
})
+9 -8
View File
@@ -66,14 +66,15 @@ const client = Layer.mock(LLMClient.Service)({
return response
}),
})
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
})
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.succeed(
@@ -57,6 +57,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
update: () => Effect.die("unused"),
commit: () => Effect.void,
}),
)
+1
View File
@@ -22,6 +22,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
update: () => Effect.die("unused"),
commit: () => Effect.void,
}),
)
@@ -19,6 +19,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
update: () => Effect.die("unused"),
commit: () => Effect.void,
}),
)
@@ -15,36 +15,6 @@ const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: Provi
const build = AgentV2.defaultID
describe("toLLMMessages", () => {
test("restores same-model provider state on assistant text", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-text-state"),
type: "assistant",
agent: build,
model,
content: [
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
state: { itemId: "msg_commentary", phase: "commentary" },
}),
],
time: { created, completed: created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: { provider: { itemId: "msg_commentary", phase: "commentary" } },
},
])
})
test("omits empty assistant turns", () => {
const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
SessionMessage.Assistant.make({
@@ -691,11 +661,6 @@ Recent work
agent: build,
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({
type: "text",
text: "Checking",
state: { itemId: "msg_old", phase: "commentary" },
}),
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Visible thought",
@@ -740,7 +705,6 @@ Recent work
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Checking", providerMetadata: undefined },
{ type: "text", text: "Visible thought" },
{
type: "tool-call",
@@ -73,14 +73,15 @@ const model = OpenAIChat.route
generation: { maxTokens: 20, temperature: 0 },
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
})
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
+11 -55
View File
@@ -280,17 +280,18 @@ const echo = Layer.effectDiscard(
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
let modelResolveHook = Effect.void
let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
variant: session.model?.variant,
}),
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: (session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
variant: session.model?.variant,
}),
),
),
),
)
})
const systemContextKey = Instructions.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
@@ -2434,51 +2435,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("preserves assistant text provider state across durable continuation", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Check this")
const providerMetadata = { openai: { itemId: "msg_commentary", phase: "commentary" } }
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "msg_commentary", providerMetadata }),
LLMEvent.textDelta({ id: "msg_commentary", text: "Checking.", providerMetadata }),
LLMEvent.textEnd({ id: "msg_commentary", providerMetadata }),
LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
reply.text("Done", "text-final"),
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(requests[1]?.messages[1]?.content[0]).toEqual({
type: "text",
text: "Checking.",
providerMetadata,
})
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Check this" },
{
type: "assistant",
content: [
{
type: "text",
text: "Checking.",
state: { itemId: "msg_commentary", phase: "commentary" },
},
{ type: "tool", id: "call-echo" },
],
},
{ type: "assistant", content: [{ type: "text", text: "Done" }] },
])
}),
)
it.effect("reloads a model switch before a tool-driven continuation step", () =>
Effect.gen(function* () {
const session = yield* setup
+2 -2
View File
@@ -88,8 +88,8 @@ describe("search tools", () => {
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }])
expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }])
expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }])
expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }])
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}),
+1
View File
@@ -152,6 +152,7 @@ describeHg("Vcs mercurial", () => {
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: "default", defaultBranch: "default" })
expect(yield* vcs.diff("branch")).toEqual([])
yield* Effect.promise(async () => {
+2
View File
@@ -53,6 +53,7 @@ describe("Vcs", () => {
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({})
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
@@ -155,6 +156,7 @@ describe("Vcs", () => {
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: "main", defaultBranch: "main" })
expect(yield* vcs.diff("branch")).toEqual([])
yield* Effect.promise(async () => {
+3
View File
@@ -30,6 +30,7 @@ import { CredentialGroup } from "./groups/credential.js"
import { ProjectGroup } from "./groups/project.js"
import { ProjectCopyGroup } from "./groups/project-copy.js"
import { VcsGroup } from "./groups/vcs.js"
import { PathGroup } from "./groups/path.js"
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
@@ -50,6 +51,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof PathGroup, LocationId>
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
@@ -168,6 +170,7 @@ const makeApiFromGroup = <
.add(ReferenceGroup.middleware(locationMiddleware))
.add(ProjectCopyGroup.middleware(locationMiddleware))
.add(VcsGroup.middleware(locationMiddleware))
.add(PathGroup.middleware(locationMiddleware))
.add(DebugGroup)
.annotateMerge(
OpenApi.annotations({
+3 -2
View File
@@ -60,7 +60,8 @@ export const groupNames = {
"server.project": "project",
"server.projectCopy": "projectCopy",
"server.vcs": "vcs",
"server.path": "path",
} as const
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
export const promiseOmitEndpoints = new Set(["pty.connect"])
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect"])
+10
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
import { Project } from "@opencode-ai/schema/project"
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
"InvalidRequestError",
@@ -62,6 +63,15 @@ export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFo
{ httpApiStatus: 404 },
) {}
export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoundError>()(
"ProjectNotFoundError",
{
projectID: Project.ID,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"SessionNotFoundError",
{
+20
View File
@@ -0,0 +1,20 @@
import { Path } from "@opencode-ai/schema/path"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const PathGroup = HttpApiGroup.make("server.path")
.add(
HttpApiEndpoint.get("path.get", "/api/path", {
query: LocationQuery,
success: Path.Info,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.path.get",
summary: "Get paths",
description: "Get process and location paths.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "path", description: "Location and process paths." }))
+15
View File
@@ -2,6 +2,7 @@ import { Project } from "@opencode-ai/schema/project"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { ProjectNotFoundError } from "../errors.js"
const root = "/api/project"
@@ -17,6 +18,20 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
}),
),
)
.add(
HttpApiEndpoint.patch("project.update", `${root}/:projectID`, {
params: { projectID: Project.ID },
payload: Project.UpdateInput,
success: Project.Info,
error: ProjectNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.project.update",
summary: "Update project",
description: "Update project metadata.",
}),
),
)
.add(
HttpApiEndpoint.get("project.current", `${root}/current`, {
query: LocationQuery,
+16 -1
View File
@@ -19,6 +19,20 @@ export function hasPtyConnectTicketURL(url: URL) {
}
export const PtyGroup = HttpApiGroup.make("server.pty")
.add(
HttpApiEndpoint.get("pty.shells", "/api/pty/shells", {
query: LocationQuery,
success: Location.response(Schema.Array(Pty.Shell)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.shells",
summary: "List available shells",
description: "List shells available for interactive terminal sessions.",
}),
),
)
.add(
HttpApiEndpoint.get("pty.list", "/api/pty", {
query: LocationQuery,
@@ -101,13 +115,14 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: Pty.ID },
query: LocationQuery,
headers: Schema.Struct({ [PTY_CONNECT_TOKEN_HEADER]: Schema.optional(Schema.String) }),
success: Location.response(PtyTicket.ConnectToken),
error: [ForbiddenError, PtyNotFoundError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.connect.token",
identifier: "v2.pty.connectToken",
summary: "Create PTY WebSocket token",
description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.",
}),
+15
View File
@@ -269,6 +269,21 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.archive", "/api/session/:sessionID/archive", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.archive",
summary: "Archive session",
description: "Archive a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
params: { sessionID: Session.ID },
+14
View File
@@ -13,6 +13,20 @@ const DiffQuery = Schema.Struct({
})
export const VcsGroup = HttpApiGroup.make("server.vcs")
.add(
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
query: LocationQuery,
success: Location.response(Vcs.Info),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.get",
summary: "VCS information",
description: "Get the current and default branches for the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("vcs.status", "/api/vcs/status", {
query: LocationQuery,
+1
View File
@@ -16,6 +16,7 @@ export const Info = Schema.Struct({
agent: Agent.ID.pipe(optional),
model: Model.Ref.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
source: Schema.Literals(["command", "mcp"]).pipe(optional),
}).annotate({ identifier: "Command.Info" })
export const Event = {
+1
View File
@@ -14,6 +14,7 @@ export { Model } from "./model.js"
export { Money } from "./money.js"
export { Permission } from "./permission.js"
export { PermissionSaved } from "./permission-saved.js"
export { Path } from "./path.js"
export { Project } from "./project.js"
export { ProjectCopy } from "./project-copy.js"
export { Provider } from "./provider.js"
+13
View File
@@ -0,0 +1,13 @@
export * as Path from "./path.js"
import { Schema } from "effect"
import { AbsolutePath } from "./schema.js"
export const Info = Schema.Struct({
home: AbsolutePath,
state: AbsolutePath,
config: AbsolutePath,
worktree: AbsolutePath,
directory: AbsolutePath,
}).annotate({ identifier: "Path.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+7
View File
@@ -56,5 +56,12 @@ export const Info = Schema.Struct({
}).annotate({ identifier: "Project" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = Schema.Struct({
name: optional(Schema.String),
icon: optional(Icon),
commands: optional(Commands),
}).annotate({ identifier: "Project.UpdateInput" })
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
const Updated = ephemeral({ type: "project.updated", schema: Info.fields })
export const Event = { Updated, Definitions: inventory(Updated) }
+7
View File
@@ -56,3 +56,10 @@ export const UpdateInput = Schema.Struct({
),
})
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
export const Shell = Schema.Struct({
path: Schema.String,
name: Schema.String,
acceptable: Schema.Boolean,
}).annotate({ identifier: "Pty.Shell" })
export interface Shell extends Schema.Schema.Type<typeof Shell> {}
+8 -2
View File
@@ -85,6 +85,13 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const Archived = Event.durable({
type: "session.archived",
...options,
schema: Base,
})
export type Archived = typeof Archived.Type
export const UsageRecorded = Event.durable({
type: "session.usage.recorded",
...options,
@@ -290,7 +297,6 @@ export namespace Text {
...Base,
assistantMessageID: SessionMessage.ID,
ordinal: NonNegativeInt,
state: SessionMessage.ProviderState.pipe(optional),
},
})
export type Started = typeof Started.Type
@@ -315,7 +321,6 @@ export namespace Text {
assistantMessageID: SessionMessage.ID,
ordinal: NonNegativeInt,
text: Schema.String,
state: SessionMessage.ProviderState.pipe(optional),
},
})
export type Ended = typeof Ended.Type
@@ -541,6 +546,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
Archived,
UsageUpdated,
Deleted,
Forked,
-1
View File
@@ -158,7 +158,6 @@ export interface AssistantText extends Schema.Schema.Type<typeof AssistantText>
export const AssistantText = Schema.Struct({
type: Schema.tag("text"),
text: Schema.String,
state: ProviderState.pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Text" })
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
+7 -1
View File
@@ -1,7 +1,7 @@
export * as Vcs from "./vcs.js"
import { Schema } from "effect"
import { NonNegativeInt } from "./schema.js"
import { NonNegativeInt, optional } from "./schema.js"
export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" })
export type Mode = typeof Mode.Type
@@ -13,3 +13,9 @@ export const FileStatus = Schema.Struct({
status: Schema.Literals(["added", "deleted", "modified"]),
}).annotate({ identifier: "Vcs.FileStatus" })
export interface FileStatus extends Schema.Schema.Type<typeof FileStatus> {}
export const Info = Schema.Struct({
branch: optional(Schema.String),
defaultBranch: optional(Schema.String),
}).annotate({ identifier: "Vcs.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
@@ -104,6 +104,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.archived.1",
"session.usage.recorded.1",
"session.forked.2",
"session.input.promoted.1",
+2
View File
@@ -26,6 +26,7 @@ import { CredentialHandler } from "./handlers/credential"
import { ProjectHandler } from "./handlers/project"
import { ProjectCopyHandler } from "./handlers/project-copy"
import { VcsHandler } from "./handlers/vcs"
import { PathHandler } from "./handlers/path"
import { EventFeed } from "./event-feed"
export const handlers = Layer.mergeAll(
@@ -56,4 +57,5 @@ export const handlers = Layer.mergeAll(
ReferenceHandler,
ProjectCopyHandler,
VcsHandler,
PathHandler,
)
+22
View File
@@ -0,0 +1,22 @@
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const PathHandler = HttpApiBuilder.group(Api, "server.path", (handlers) =>
handlers.handle("path.get", () =>
Effect.gen(function* () {
const global = yield* Global.Service
const location = yield* Location.Service
return {
home: AbsolutePath.make(global.home),
state: AbsolutePath.make(global.state),
config: AbsolutePath.make(global.config),
worktree: location.project.directory,
directory: location.directory,
}
}),
),
)
+16
View File
@@ -3,10 +3,26 @@ import { Project } from "@opencode-ai/core/project"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ProjectNotFoundError } from "@opencode-ai/protocol/errors"
export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) =>
handlers
.handle("project.list", () => Project.Service.use((project) => project.list()))
.handle("project.update", (ctx) =>
Effect.gen(function* () {
const project = yield* Project.Service
return yield* project.update(ctx.params.projectID, ctx.payload).pipe(
Effect.catchTag(
"Project.NotFoundError",
() =>
new ProjectNotFoundError({
projectID: ctx.params.projectID,
message: `Project not found: ${ctx.params.projectID}`,
}),
),
)
}),
)
.handle("project.current", () =>
Location.Service.use((location) =>
Effect.succeed({ id: location.project.id, directory: location.project.directory }),
+7
View File
@@ -29,6 +29,13 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
const environment = yield* PtyEnvironment.Service
return handlers
.handle(
"pty.shells",
Effect.fn(function* () {
const pty = yield* Pty.Service
return yield* response(pty.shells())
}),
)
.handle(
"pty.list",
Effect.fn(function* () {
+16
View File
@@ -201,6 +201,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.archive",
Effect.fn(function* (ctx) {
yield* session.archive(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.move",
Effect.fn(function* (ctx) {
+8
View File
@@ -7,6 +7,14 @@ import { response } from "../location"
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("vcs.get", () =>
response(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.info()
}),
),
)
.handle("vcs.status", () =>
response(
Effect.gen(function* () {
+4 -1
View File
@@ -60,6 +60,7 @@ const applicationServices = LayerNode.group([
WellKnown.node,
PtyEnvironment.node,
LocationServiceMap.node,
Global.node,
SessionRestart.node,
])
@@ -134,7 +135,9 @@ function makeRoutes<AuthError, AuthServices>(
Layer.flatMap((context) => {
const services = Layer.succeedContext(context)
const requestServices = Layer.merge(
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)),
Layer.succeedContext(
Context.pick(Global.Service, PermissionSaved.Service, Project.Service, WellKnown.Service)(context),
),
ServerInfo.layer(serviceURLs, options.app),
)
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
+2 -4
View File
@@ -4,10 +4,8 @@ import {
DEFAULT_THEMES,
addTheme,
allThemes,
generateSyntax,
hasTheme,
isTheme,
resolveTheme,
selectedForeground,
setCustomThemes,
setSystemTheme,
@@ -16,6 +14,7 @@ import {
type Theme,
type ThemeJson,
} from "../theme"
import { generateSyntax } from "../theme/v2/syntax"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes, themeDirectories } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
@@ -279,7 +278,6 @@ const themeContext = createSimpleContext({
if (supported.includes(store.mode)) return store.mode
return supported[0] ?? store.mode
}
const legacySyntaxTheme = createMemo(() => resolveTheme(source(), mode()))
const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName()))
valuesV2()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
@@ -299,7 +297,7 @@ const themeContext = createSimpleContext({
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
const syntax = createSyntaxStyleMemo(() => generateSyntax(legacySyntaxTheme()))
const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
function contextual(context: ContextName) {
return contextualServices[context]
}
+93
View File
@@ -0,0 +1,93 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeView } from "./index"
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
const step = mode === "light" ? 800 : 200
const syntax = theme.syntax
const markdown = theme.markdown
const feedback = theme.text.feedback
return SyntaxStyle.fromTheme([
rule(["default"], theme.text.default),
rule(["prompt"], theme.hue.accent[step]),
rule(["extmark.file"], feedback.warning.default, { bold: true }),
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
// V1 migration preserves its selected/inverse foreground in this action state.
rule(["extmark.paste"], theme.text.action.primary.focused, {
background: feedback.warning.default,
bold: true,
}),
rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
rule(["string", "symbol", "character.special", "character"], syntax.string),
rule(["number", "boolean", "constant", "float"], syntax.number),
rule(["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], syntax.keyword, {
italic: true,
}),
rule(["keyword.type"], syntax.type, { bold: true, italic: true }),
rule(["keyword.function", "function.method"], syntax.function),
rule(["keyword"], syntax.keyword, { italic: true }),
rule(["keyword.import", "string.escape", "string.regexp", "tag.attribute", "keyword.export"], syntax.keyword),
rule(["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary"], syntax.operator),
rule(
["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"],
syntax.variable,
),
rule(["variable.member", "function", "constructor"], syntax.function),
rule(["type", "module", "class", "namespace"], syntax.type),
rule(["type.definition"], syntax.type, { bold: true }),
rule(["punctuation", "punctuation.bracket"], syntax.punctuation),
rule(
["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
feedback.error.default,
),
rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }),
rule(["punctuation.special", "tag.delimiter"], syntax.operator),
rule(
[
"markup.heading",
"markup.heading.2",
"markup.heading.3",
"markup.heading.4",
"markup.heading.5",
"markup.heading.6",
],
markdown.heading,
{ bold: true },
),
rule(["markup.heading.1"], markdown.heading, { bold: true, underline: true }),
rule(["markup.bold", "markup.strong"], markdown.strong, { bold: true }),
rule(["markup.italic"], markdown.emphasis, { italic: true }),
rule(["markup.list"], markdown.listItem),
rule(["markup.quote"], markdown.blockQuote, { italic: true }),
rule(["markup.raw", "markup.raw.block"], markdown.code),
rule(["markup.raw.inline"], markdown.code, { background: theme.background.default }),
rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, {
underline: true,
}),
rule(["markup.link.label"], markdown.linkText, { underline: true }),
rule(["label"], markdown.linkText),
rule(["spell", "nospell"], theme.text.default),
rule(["markup.underline"], theme.text.default, { underline: true }),
rule(["comment.error"], feedback.error.default, { italic: true, bold: true }),
rule(["comment.warning"], feedback.warning.default, { italic: true, bold: true }),
rule(["comment.todo", "comment.note"], feedback.info.default, { italic: true, bold: true }),
rule(["attribute", "annotation"], feedback.warning.default),
rule(["tag"], feedback.error.default),
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.subdued),
rule(["markup.list.checked"], feedback.success.default),
rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }),
rule(["error"], feedback.error.default, { bold: true }),
rule(["warning"], feedback.warning.default, { bold: true }),
rule(["info"], feedback.info.default),
])
}
function rule(
scope: string[],
foreground: RGBA,
style: Omit<ThemeTokenStyle["style"], "foreground"> = {},
): ThemeTokenStyle {
return { scope, style: { foreground, ...style } }
}
+1
View File
@@ -0,0 +1 @@
.build/
+4
View File
@@ -0,0 +1,4 @@
preload = ["@opentui/solid/preload"]
[test]
preload = ["@opentui/solid/preload"]
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/voice",
"version": "0.0.0",
"private": true,
"description": "Prototype voice control for OpenCode via the OpenAI Realtime and Live APIs",
"type": "module",
"scripts": {
"spike": "bun run --no-orphans --conditions=browser src/spike.ts",
"test": "bun test",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
},
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"effect": "catalog:",
"opentui-spinner": "catalog:",
"solid-js": "1.9.12"
}
}
+33
View File
@@ -0,0 +1,33 @@
export type TextReveal = { readonly offset: number; readonly at: number }
const REVEAL_DURATION_MS = 320
const REVEAL_STAGGER_MS = 32
const REVEAL_MAX_QUEUE_MS = 160
export const REVEAL_WORD_LIMIT = 24
export function springOpacity(now: number, start: number) {
const progress = Math.max(0, Math.min(1, (now - start) / REVEAL_DURATION_MS))
if (progress === 1) return 1
const time = progress * 8
return 1 - (1 + time) * Math.exp(-time)
}
export function transcriptionPulse(now: number) {
return 0.5 + Math.sin(now / 220) * 0.5
}
export function scheduleTextReveal(previous: string, delta: string, now: number, revealAt: number) {
const text = previous + delta
const offsets = Array.from(delta.matchAll(/\S+/g))
.map((match) => previous.length + match.index)
.filter((offset) => offset === 0 || /\s/.test(text[offset - 1] ?? ""))
.slice(-REVEAL_WORD_LIMIT)
const start = Math.min(Math.max(revealAt, now), now + REVEAL_MAX_QUEUE_MS)
const reveals = offsets.map((offset, word) => ({ offset, at: start + word * REVEAL_STAGGER_MS }))
const nextRevealAt = reveals.length === 0 ? revealAt : reveals.at(-1)!.at + REVEAL_STAGGER_MS
return {
reveals,
nextRevealAt,
animationEndsAt: reveals.length === 0 ? now : reveals.at(-1)!.at + REVEAL_DURATION_MS,
}
}
+41
View File
@@ -0,0 +1,41 @@
import { PCM_BYTES_PER_MS } from "./pcm"
export type PlaybackChunk = {
readonly bytes: Buffer
readonly gapMs: number
}
export class AudioJitterBuffer {
private readonly pending: PlaybackChunk[] = []
private durationMs = 0
private started = false
constructor(private readonly targetMs = 500) {}
push(chunk: PlaybackChunk): ReadonlyArray<PlaybackChunk> {
if (this.started) return [chunk]
this.pending.push(chunk)
this.durationMs += chunk.gapMs + chunk.bytes.length / PCM_BYTES_PER_MS
if (this.durationMs < this.targetMs) return []
this.started = true
return this.drain()
}
finish(): ReadonlyArray<PlaybackChunk> {
if (this.pending.length === 0) return []
this.started = true
return this.drain()
}
reset() {
this.pending.length = 0
this.durationMs = 0
this.started = false
}
private drain() {
const chunks = this.pending.splice(0)
this.durationMs = 0
return chunks
}
}
+68
View File
@@ -0,0 +1,68 @@
import { mkdir, rename } from "node:fs/promises"
import { homedir } from "node:os"
import { dirname, join } from "node:path"
import { Option, Schema } from "effect"
import { OpenCodeNotification } from "./opencode-notification"
export type CompletionHandle = { readonly sessionID: string; readonly promptID: string }
export type StoredCompletion =
| { readonly status: "admitting"; readonly handle: CompletionHandle; readonly text: string }
| { readonly status: "pending"; readonly handle: CompletionHandle }
| {
readonly status: "completed"
readonly handle: CompletionHandle
readonly notification: OpenCodeNotification
}
const CompletionHandle = Schema.Struct({ sessionID: Schema.String, promptID: Schema.String })
const StoredCompletion = Schema.Union([
Schema.Struct({ status: Schema.Literal("admitting"), handle: CompletionHandle, text: Schema.String }),
Schema.Struct({ status: Schema.Literal("pending"), handle: CompletionHandle }),
Schema.Struct({ status: Schema.Literal("completed"), handle: CompletionHandle, notification: OpenCodeNotification }),
])
const StoredCompletions = Schema.Array(StoredCompletion)
export async function createCompletionStore(
path = join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "voice-prompts.json"),
) {
await mkdir(dirname(path), { recursive: true })
const decoded = (await Bun.file(path).exists())
? Option.getOrUndefined(Schema.decodeUnknownOption(StoredCompletions)(await Bun.file(path).json()))
: []
if (!decoded) throw new Error(`Invalid voice completion store: ${path}`)
const entries = new Map(decoded.map((entry) => [entry.handle.promptID, entry]))
let writes = Promise.resolve()
const save = () => {
const json = JSON.stringify([...entries.values()])
writes = writes.then(async () => {
const temporary = `${path}.${process.pid}.tmp`
await Bun.write(temporary, json)
await rename(temporary, path)
})
return writes
}
return {
entries: () => [...entries.values()],
admitting(handle: CompletionHandle, text: string) {
entries.set(handle.promptID, { status: "admitting", handle, text })
return save()
},
pending(handle: CompletionHandle) {
entries.set(handle.promptID, { status: "pending", handle })
return save()
},
completed(handle: CompletionHandle, notification: OpenCodeNotification) {
entries.set(handle.promptID, { status: "completed", handle, notification })
return save()
},
delivered(promptID: string) {
if (!entries.delete(promptID)) return writes
return save()
},
close: () => writes,
}
}
export type CompletionStore = Awaited<ReturnType<typeof createCompletionStore>>
+215
View File
@@ -0,0 +1,215 @@
// Full-duplex terminal audio bridge with Apple voice processing (AEC).
//
// stdin <- raw PCM16 mono 24kHz to play through the speakers
// stdout -> raw PCM16 mono 24kHz captured from the microphone
// SIGUSR1: drop any queued speaker audio (barge-in flush)
//
// Two independent engines: attaching a speaker source to a voice-processed
// engine silently kills its input tap, so playback runs on its own plain
// engine. Voice processing (echo cancellation) is attempted on the input
// engine and abandoned if the mic delivers nothing: on some devices
// (Bluetooth headsets mid-negotiation) the VP tap never fires. Without VP
// there is no echo cancellation, which is acceptable exactly when it happens:
// headphones have no echo path.
//
// Compiled on demand by spike.ts: swiftc -O duplex-audio.swift -o duplex-audio
import AVFoundation
let stderr = FileHandle.standardError
let sampleRate = 24000.0
let speakerCapacity = Int(sampleRate) * 120
func log(_ message: String) { stderr.write(Data("[audio] \(message)\n".utf8)) }
final class SpeakerQueue {
private let capacity = speakerCapacity
private var samples = [Int16](repeating: 0, count: speakerCapacity)
private var readIndex = 0
private var count = 0
private let lock = NSLock()
func push(_ chunk: Data) {
chunk.withUnsafeBytes { raw in
let input = raw.bindMemory(to: Int16.self)
lock.lock()
for sample in input {
if count == capacity {
samples[readIndex] = sample
readIndex = (readIndex + 1) % capacity
} else {
samples[(readIndex + count) % capacity] = sample
count += 1
}
}
lock.unlock()
}
}
func fill(_ output: UnsafeMutablePointer<Float>, frames: Int) {
lock.lock()
defer { lock.unlock() }
let available = min(frames, count)
for index in 0..<available {
output[index] = Float(samples[(readIndex + index) % capacity]) / 32768.0
}
if available < frames {
for index in available..<frames { output[index] = 0 }
}
readIndex = (readIndex + available) % capacity
count -= available
}
func flush() {
lock.lock()
readIndex = 0
count = 0
lock.unlock()
}
}
let queue = SpeakerQueue()
let playFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false)!
let captureFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: sampleRate, channels: 1, interleaved: true)!
// Speaker: its own engine, pulling PCM16 from the stdin-fed queue.
var outputEngine: AVAudioEngine?
func startOutput() {
outputEngine?.stop()
let engine = AVAudioEngine()
outputEngine = engine
let source = AVAudioSourceNode(format: playFormat) { _, _, frameCount, audioBufferList -> OSStatus in
let out = UnsafeMutableAudioBufferListPointer(audioBufferList)[0].mData!.assumingMemoryBound(to: Float.self)
queue.fill(out, frames: Int(frameCount))
return noErr
}
engine.attach(source)
engine.connect(source, to: engine.mainMixerNode, format: playFormat)
do {
try engine.start()
} catch {
log("output engine failed: \(error)")
}
}
// Microphone: take channel 0 of whatever the hardware provides, resample to
// 24kHz PCM16 for stdout. Channel extraction is manual because hardware
// channel counts vary wildly (1, 2, 3, 22...) and AVAudioConverter cannot
// downmix all of them.
var tapCount = 0
var inputEngine: AVAudioEngine?
func startInput(voiceProcessing: Bool) {
inputEngine?.stop()
let engine = AVAudioEngine()
inputEngine = engine
if voiceProcessing {
do {
try engine.inputNode.setVoiceProcessingEnabled(true)
} catch {
log("voice processing unavailable (\(error)) — no echo cancellation")
return startInput(voiceProcessing: false)
}
if #available(macOS 14.0, *) {
engine.inputNode.voiceProcessingOtherAudioDuckingConfiguration = .init(
enableAdvancedDucking: false,
duckingLevel: .min
)
}
}
let micFormat = engine.inputNode.outputFormat(forBus: 0)
let monoFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: micFormat.sampleRate, channels: 1, interleaved: false)!
guard let converter = AVAudioConverter(from: monoFormat, to: captureFormat) else {
log("cannot convert \(micFormat.sampleRate)Hz to 24kHz")
exit(2)
}
engine.inputNode.installTap(onBus: 0, bufferSize: 2400, format: micFormat) { buffer, _ in
tapCount += 1
if tapCount == 1 { log("mic active (echo cancellation \(voiceProcessing ? "on" : "off"))") }
guard let channel = buffer.floatChannelData?[0], buffer.frameLength > 0 else { return }
guard let mono = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: buffer.frameLength) else { return }
memcpy(mono.floatChannelData![0], channel, Int(buffer.frameLength) * 4)
mono.frameLength = buffer.frameLength
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * sampleRate / micFormat.sampleRate) + 32
guard let converted = AVAudioPCMBuffer(pcmFormat: captureFormat, frameCapacity: capacity) else { return }
var consumed = false
converter.convert(to: converted, error: nil) { _, status in
if consumed {
status.pointee = .noDataNow
return nil
}
consumed = true
status.pointee = .haveData
return mono
}
guard converted.frameLength > 0, let out = converted.int16ChannelData?[0] else { return }
FileHandle.standardOutput.write(Data(bytes: out, count: Int(converted.frameLength) * 2))
}
do {
try engine.start()
} catch {
if voiceProcessing {
log("input engine failed with voice processing (\(error)) — retrying without")
return startInput(voiceProcessing: false)
}
log("input engine failed: \(error)")
exit(2)
}
// Watchdog: on some devices the voice-processed tap simply never fires.
// Fall back to a plain tap; without VP the mic reliably delivers.
if voiceProcessing {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
if tapCount > 0 { return }
log("voice-processed mic delivered nothing — restarting without echo cancellation")
startInput(voiceProcessing: false)
}
}
}
// Voice processing is opt-in (--aec): it is only needed on speakers, and on
// some machines (observed with Bluetooth headsets active) the VP engine binds
// to the wrong capture device entirely, delivering noise instead of the mic.
let wantAEC = CommandLine.arguments.contains("--aec")
// Mic first: activating the mic flips Bluetooth headsets from music mode to
// headset mode, reconfiguring the output device. Starting output afterwards
// (and rebuilding on any route change below) keeps playback on the live device.
startInput(voiceProcessing: wantAEC)
startOutput()
// Device switches (Bluetooth profile flips, headphones plugged/unplugged,
// default device changes) stop engines silently. Rebuild both, debounced.
var rebuildScheduled = false
NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange, object: nil, queue: .main
) { _ in
if rebuildScheduled { return }
rebuildScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
rebuildScheduled = false
log("audio route changed — rebuilding engines")
startInput(voiceProcessing: wantAEC)
startOutput()
}
}
signal(SIGUSR1, SIG_IGN)
let flushSignal = DispatchSource.makeSignalSource(signal: SIGUSR1, queue: .main)
flushSignal.setEventHandler { queue.flush() }
flushSignal.resume()
DispatchQueue.global().async {
while true {
let chunk = FileHandle.standardInput.availableData
if chunk.isEmpty { exit(0) } // parent closed stdin
queue.push(chunk)
}
}
RunLoop.main.run()
@@ -0,0 +1,63 @@
import { Schema } from "effect"
const Completed = Schema.Struct({
type: Schema.Literal("opencode.prompt.completed"),
session_id: Schema.String,
prompt_id: Schema.String,
status: Schema.Literals(["completed", "failed"]),
text: Schema.String,
error: Schema.optional(Schema.String),
})
const Failed = Schema.Struct({
type: Schema.Literal("opencode.prompt.failed"),
session_id: Schema.String,
prompt_id: Schema.String,
status: Schema.Literal("failed"),
error: Schema.String,
})
const PermissionBlocked = Schema.Struct({
type: Schema.Literal("opencode.prompt.blocked"),
prompt_id: Schema.String,
blocker: Schema.Literal("permission"),
session_id: Schema.String,
request_id: Schema.String,
action: Schema.String,
resources: Schema.Unknown,
})
const QuestionBlocked = Schema.Struct({
type: Schema.Literal("opencode.prompt.blocked"),
prompt_id: Schema.String,
blocker: Schema.Literal("question"),
session_id: Schema.String,
request_id: Schema.String,
questions: Schema.Unknown,
})
const FormBlocked = Schema.Struct({
type: Schema.Literal("opencode.prompt.blocked"),
prompt_id: Schema.String,
blocker: Schema.Literal("form"),
session_id: Schema.String,
form_id: Schema.String,
title: Schema.String,
fields: Schema.Unknown,
})
const EventsFailed = Schema.Struct({
type: Schema.Literal("opencode.events.failed"),
error: Schema.String,
})
export const OpenCodeNotification = Schema.Union([
Completed,
Failed,
PermissionBlocked,
QuestionBlocked,
FormBlocked,
EventsFailed,
])
export type OpenCodeNotification = typeof OpenCodeNotification.Type
export type OpenCodePromptBlocked = Extract<OpenCodeNotification, { readonly type: "opencode.prompt.blocked" }>
+764
View File
@@ -0,0 +1,764 @@
import { realpathSync } from "node:fs"
import { tmpdir } from "node:os"
import { basename, isAbsolute, relative } from "node:path"
import { OpenCode } from "@opencode-ai/client/promise"
import type { SessionMessageAssistant, SessionMessageUser, V2Event } from "@opencode-ai/client/promise"
import { Form, Question, SessionMessage } from "@opencode-ai/client/effect"
import { Context, Effect, FiberMap, Layer, ManagedRuntime, Option, Schema, Stream } from "effect"
import { createCompletionStore, type CompletionStore } from "./completion-store"
import type { OpenCodeNotification, OpenCodePromptBlocked } from "./opencode-notification"
import type { VoiceTool } from "./protocol"
type Client = ReturnType<typeof OpenCode.make>
type PromptHandle = { readonly sessionID: string; readonly promptID: string }
type Tool = {
readonly description: string
readonly parameters: unknown
readonly execute: (input: Record<string, unknown>) => Effect.Effect<unknown, unknown>
}
type BridgeApi = {
readonly definitions: ReadonlyArray<VoiceTool>
readonly execute: (name: string, input: Record<string, unknown>) => Effect.Effect<unknown, unknown>
readonly delivered: (promptID: string) => Effect.Effect<void, unknown>
readonly close: Effect.Effect<void>
}
class Bridge extends Context.Service<Bridge, BridgeApi>()("@opencode-ai/voice/OpenCodeBridge") {}
export async function createOpenCodeBridge(options: {
client: Client
directory: string
model: { readonly providerID: string; readonly id: string; readonly variant?: string }
notify: (notification: OpenCodeNotification) => void
onSession: (sessionID: string) => void
trace?: (event: string, data?: Record<string, unknown>) => void
completionStore?: CompletionStore
}) {
const completionStore = options.completionStore ?? (await createCompletionStore())
const runtime = ManagedRuntime.make(Layer.effect(Bridge, makeBridge({ ...options, completionStore })))
const bridge = await runtime.runPromise(Bridge)
return {
definitions: bridge.definitions,
execute: (name: string, input: Record<string, unknown>) => runtime.runPromise(bridge.execute(name, input)),
delivered: (promptID: string) => runtime.runPromise(bridge.delivered(promptID)),
close: async () => {
await runtime.runPromise(bridge.close)
await completionStore.close()
await Promise.race([runtime.dispose(), Bun.sleep(1_000)])
},
}
}
const makeBridge = Effect.fnUntraced(function* (options: {
client: Client
directory: string
model: { readonly providerID: string; readonly id: string; readonly variant?: string }
notify: (notification: OpenCodeNotification) => void
onSession: (sessionID: string) => void
trace?: (event: string, data?: Record<string, unknown>) => void
completionStore: CompletionStore
}) {
const knownProjects = new Set<string>()
const knownSessions = new Set<string>()
const registrations = new Map<string, PromptHandle>()
const promoted = new Map<string, string>()
const latest = new Map<string, string>()
const announcedBlockers = new Set<string>()
const completions = yield* FiberMap.make<string>()
const eventAbort = yield* Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(controller) => Effect.sync(() => controller.abort()),
)
const notify = (value: OpenCodeNotification) => Effect.sync(() => options.notify(value))
const trace = (event: string, data: Record<string, unknown>) => Effect.sync(() => options.trace?.(event, data))
const request = <A>(run: (signal: AbortSignal) => PromiseLike<A>) =>
Effect.tryPromise({ try: run, catch: (cause) => cause })
const announceBlocker = (key: string, notification: OpenCodeNotification) => {
if (announcedBlockers.has(key)) return Effect.void
announcedBlockers.add(key)
return notify(notification)
}
const clearRegistration = (handle: PromptHandle) =>
Effect.sync(() => {
registrations.delete(handle.promptID)
if (promoted.get(handle.sessionID) === handle.promptID) promoted.delete(handle.sessionID)
if (latest.get(handle.sessionID) === handle.promptID) latest.delete(handle.sessionID)
})
const listProjects = Effect.fnUntraced(function* () {
const seen = new Set<string>()
const temporary = realpathSync(tmpdir())
return (yield* request((signal) => options.client.project.list({ signal })))
.sort((a, b) => b.time.updated - a.time.updated)
.filter((project) => {
const fromTemporary = relative(temporary, project.worktree)
if (
project.id === "global" ||
fromTemporary === "" ||
(!fromTemporary.startsWith("..") && !isAbsolute(fromTemporary)) ||
seen.has(project.worktree)
)
return false
seen.add(project.worktree)
return true
})
})
const projectDirectory = Effect.fnUntraced(function* (projectID: string) {
if (!knownProjects.has(projectID)) return undefined
return (yield* listProjects()).find((project) => project.id === projectID)?.worktree
})
const complete = Effect.fnUntraced(function* (handle: PromptHandle) {
yield* trace("opencode.wait.started", handle)
yield* request((signal) => options.client.session.wait({ sessionID: handle.sessionID }, { signal }))
const reply = yield* request((signal) => finalReply(options.client, handle, signal))
yield* trace("opencode.wait.completed", { ...handle, replyID: reply?.id, error: reply?.error?.message })
const notification: OpenCodeNotification = {
type: "opencode.prompt.completed",
session_id: handle.sessionID,
prompt_id: handle.promptID,
status: reply?.error ? "failed" : "completed",
text: reply
? assistantText(reply) || "OpenCode finished without a text reply."
: "OpenCode finished without a reply.",
error: reply?.error?.message,
}
yield* request(() => options.completionStore.completed(handle, notification))
yield* notify(notification)
})
const register = Effect.fnUntraced(function* (handle: PromptHandle) {
yield* complete(handle).pipe(
Effect.catch((error) => {
const notification: OpenCodeNotification = {
type: "opencode.prompt.failed",
session_id: handle.sessionID,
prompt_id: handle.promptID,
status: "failed",
error: String(error),
}
return request(() => options.completionStore.completed(handle, notification)).pipe(
Effect.andThen(notify(notification)),
)
}),
Effect.ensuring(clearRegistration(handle)),
FiberMap.run(completions, handle.promptID, { onlyIfMissing: true, startImmediately: true }),
)
})
const restoreBlockers = Effect.fnUntraced(function* (handle: PromptHandle) {
const [permissions, questions, forms] = yield* Effect.all(
[
request((signal) => options.client.permission.list({ sessionID: handle.sessionID }, { signal })),
request((signal) => options.client.question.list({ sessionID: handle.sessionID }, { signal })),
request((signal) => options.client.form.list({ sessionID: handle.sessionID }, { signal })),
],
{ concurrency: "unbounded" },
)
yield* Effect.forEach(
[
...permissions.map((item) => ({
key: `permission:${item.id}`,
notification: {
type: "opencode.prompt.blocked",
prompt_id: handle.promptID,
blocker: "permission",
session_id: handle.sessionID,
request_id: item.id,
action: item.action,
resources: item.resources,
} satisfies OpenCodeNotification,
})),
...questions.map((item) => ({
key: `question:${item.id}`,
notification: {
type: "opencode.prompt.blocked",
prompt_id: handle.promptID,
blocker: "question",
session_id: handle.sessionID,
request_id: item.id,
questions: item.questions,
} satisfies OpenCodeNotification,
})),
...forms.map((item) => ({
key: `form:${item.id}`,
notification: {
type: "opencode.prompt.blocked",
prompt_id: handle.promptID,
blocker: "form",
session_id: handle.sessionID,
form_id: item.id,
title: item.title,
fields: item.fields,
} satisfies OpenCodeNotification,
})),
],
(item) => announceBlocker(item.key, item.notification),
{ discard: true },
)
})
const admit = Effect.fnUntraced(function* (sessionID: string, text: string) {
const handle = { sessionID, promptID: SessionMessage.ID.create() }
registrations.set(handle.promptID, handle)
latest.set(sessionID, handle.promptID)
yield* trace("opencode.prompt.admitting", handle)
yield* request(() => options.completionStore.admitting(handle, text))
const admitted = yield* request((signal) =>
options.client.session.prompt({ sessionID, id: handle.promptID, text }, { signal }),
).pipe(
Effect.tapError(() =>
request(() => options.completionStore.delivered(handle.promptID)).pipe(
Effect.andThen(clearRegistration(handle)),
),
),
)
knownSessions.add(sessionID)
yield* request(() => options.completionStore.pending(handle))
yield* trace("opencode.prompt.admitted", { ...handle, admittedSeq: admitted.admittedSeq })
options.onSession(sessionID)
yield* register(handle)
return {
status: "started",
session_id: sessionID,
prompt_id: admitted.id,
notification: "registered",
message: "OpenCode accepted the prompt and will send a completion notification. Continue the conversation now.",
}
})
const start = Effect.fnUntraced(function* (text: string, projectID?: string) {
const directory = projectID ? yield* projectDirectory(projectID) : options.directory
if (!directory) return toolError("Use a project ID returned by find_projects.")
const session = yield* request((signal) =>
options.client.session.create({ location: { directory }, model: options.model }, { signal }),
)
return yield* admit(session.id, text)
})
const tools: Record<string, Tool> = {
find_projects: {
description: "Find known OpenCode projects by display name. Returns opaque IDs, never filesystem paths.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
query: { type: ["string", "null"], description: "Name fragment, or null for recent projects." },
limit: { type: "integer", minimum: 1, maximum: 20 },
},
required: ["query", "limit"],
},
execute: Effect.fnUntraced(function* (input) {
const query = typeof input["query"] === "string" ? input["query"].toLowerCase() : undefined
const limit = typeof input["limit"] === "number" ? input["limit"] : 10
const projects = (yield* listProjects())
.filter((project) => !query || projectLabel(project).toLowerCase().includes(query))
.slice(0, limit)
projects.forEach((project) => knownProjects.add(project.id))
return {
status: "ok",
projects: projects.map((project) => ({
id: project.id,
name: projectLabel(project),
directories: 1 + project.sandboxes.length,
updated: new Date(project.time.updated).toISOString(),
})),
}
}),
},
find_sessions: {
description:
"Find root OpenCode sessions by title and recency. Search the launch project by default; use all_projects only when requested or the local search misses.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
query: { type: ["string", "null"], description: "Title words, or null for recent sessions." },
scope: { type: "string", enum: ["current_project", "all_projects"] },
recency: { type: "string", enum: ["day", "week", "month", "any"] },
limit: { type: "integer", minimum: 1, maximum: 20 },
},
required: ["query", "scope", "recency", "limit"],
},
execute: Effect.fnUntraced(function* (input) {
const query = typeof input["query"] === "string" ? input["query"] : undefined
const scope = input["scope"] === "all_projects" ? "all_projects" : "current_project"
const recency = typeof input["recency"] === "string" ? input["recency"] : "any"
const limit = typeof input["limit"] === "number" ? input["limit"] : 10
const durations: Record<string, number> = { day: 86_400_000, week: 604_800_000, month: 2_592_000_000 }
const threshold = recency === "any" ? 0 : Date.now() - (durations[recency] ?? 0)
const [result, projectList] = yield* Effect.all(
[
request((signal) =>
options.client.session.list(
{
...(scope === "current_project" ? { directory: options.directory } : {}),
search: query,
parentID: null,
limit: recency === "any" ? limit : Math.min(limit * 5, 100),
order: "desc",
},
{ signal },
),
),
listProjects(),
],
{ concurrency: "unbounded" },
)
const projects = new Map(projectList.map((project) => [project.id, projectLabel(project)]))
const sessions = result.data
.filter((session) => projects.has(session.projectID) && session.time.updated >= threshold)
.slice(0, limit)
sessions.forEach((session) => knownSessions.add(session.id))
return {
status: "ok",
scope,
sessions: sessions.map((session) => ({
id: session.id,
title: session.title,
project: projects.get(session.projectID) ?? "project",
updated: new Date(session.time.updated).toISOString(),
})),
}
}),
},
read_session: {
description:
"Read bounded recent user and assistant text from a session returned by find_sessions or start_session. This never prompts or wakes the coding agent.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
session_id: { type: "string", description: "Session ID returned by a previous voice tool." },
limit: { type: "integer", minimum: 1, maximum: 20 },
},
required: ["session_id", "limit"],
},
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
if (!sessionID) return toolError("Use a session ID returned by find_sessions or start_session.")
const limit = typeof input["limit"] === "number" ? input["limit"] : 10
const [session, messages] = yield* Effect.all(
[
request((signal) => options.client.session.get({ sessionID }, { signal })),
request((signal) => options.client.message.list({ sessionID, order: "desc", limit }, { signal })),
],
{ concurrency: "unbounded" },
)
const latestAssistant = messages.data.find(
(message): message is SessionMessageAssistant => message.type === "assistant",
)
return {
status: "ok",
title: session.title,
running: latestAssistant ? !latestAssistant.time.completed : false,
messages: messages.data
.toReversed()
.flatMap((message) =>
message.type === "user" || message.type === "assistant" ? [sessionMessage(message)] : [],
),
}
}),
},
rename_session: {
description:
"Rename a session returned by find_sessions or start_session after the user requests a new title. This changes only the display title and does not prompt, wake, or interrupt the coding agent.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
session_id: { type: "string", description: "Session ID returned by a previous voice tool." },
title: { type: "string", description: "The complete new session title." },
},
required: ["session_id", "title"],
},
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
const title = requireString(input, "title")?.trim()
if (!sessionID) return toolError("Use a session ID returned by find_sessions or start_session.")
if (!title) return toolError("A non-empty session title is required.")
const session = yield* request((signal) => options.client.session.get({ sessionID }, { signal }))
if (session.title === title)
return { status: "unchanged", session_id: sessionID, previous_title: session.title, title }
yield* request((signal) => options.client.session.rename({ sessionID, title }, { signal }))
return { status: "renamed", session_id: sessionID, previous_title: session.title, title }
}),
},
archive_session: {
description:
"Archive a session returned by find_sessions or start_session. Call only after the user explicitly confirms the archive. This changes session visibility and does not prompt, wake, interrupt, or stop the coding agent.",
parameters: sessionParameters(),
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
if (!sessionID) return toolError("Use a session ID returned by find_sessions or start_session.")
const session = yield* request((signal) => options.client.session.get({ sessionID }, { signal }))
if (session.time.archived) return { status: "already_archived", title: session.title }
yield* request((signal) => options.client.session.archive({ sessionID }, { signal }))
return { status: "archived", title: session.title }
}),
},
start_session: {
description:
"Create an OpenCode session, admit its first prompt, and register a one-shot completion notification. Omit project_id to use the launch project.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
text: { type: "string", description: "Clear instruction for the coding agent." },
project_id: { type: ["string", "null"], description: "Opaque project ID, or null for the launch project." },
},
required: ["text", "project_id"],
},
execute: (input) => {
const text = requireString(input, "text")
if (!text) return Effect.succeed(toolError("Task text is required."))
return start(text, typeof input["project_id"] === "string" ? input["project_id"] : undefined)
},
},
prompt_session: {
description:
"Admit a prompt into a discovered or previously started OpenCode session and register a one-shot completion notification.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
session_id: { type: "string", description: "Session ID returned by a previous voice tool." },
text: { type: "string", description: "Clear instruction for the coding agent." },
},
required: ["session_id", "text"],
},
execute: (input) => {
const sessionID = knownSession(input, knownSessions)
const text = requireString(input, "text")
if (!sessionID) return Effect.succeed(toolError("Use a session ID returned by find_sessions or start_session."))
if (!text) return Effect.succeed(toolError("Task text is required."))
return admit(sessionID, text)
},
},
interrupt_session: {
description: "Interrupt one OpenCode session. Call only after the user explicitly confirms the interruption.",
parameters: sessionParameters(),
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
if (!sessionID) return toolError("Use a session ID returned by a previous voice tool.")
yield* request((signal) => options.client.session.interrupt({ sessionID }, { signal }))
return { status: "interrupted", session_id: sessionID }
}),
},
list_pending_permissions: {
description: "List permission requests blocking one OpenCode session.",
parameters: sessionParameters(),
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
if (!sessionID) return toolError("Use a session ID returned by a previous voice tool.")
const requests = yield* request((signal) => options.client.permission.list({ sessionID }, { signal }))
return {
status: "ok",
requests: requests.map((item) => ({ id: item.id, action: item.action, resources: item.resources })),
}
}),
},
reply_permission: {
description:
"Allow once or reject a pending permission after stating the action and resources and receiving the user's explicit decision.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
session_id: { type: "string" },
request_id: { type: "string" },
decision: { type: "string", enum: ["allow_once", "reject"] },
},
required: ["session_id", "request_id", "decision"],
},
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
const requestID = requireString(input, "request_id")
const decision = input["decision"]
if (!sessionID || !requestID || (decision !== "allow_once" && decision !== "reject"))
return toolError("A known session, request ID, and valid decision are required.")
const requests = yield* request((signal) => options.client.permission.list({ sessionID }, { signal }))
if (!requests.some((item) => item.id === requestID))
return toolError("That permission request is not pending.", true)
yield* request((signal) =>
options.client.permission.reply(
{ sessionID, requestID, reply: decision === "allow_once" ? "once" : "reject" },
{ signal },
),
)
return { status: decision === "allow_once" ? "allowed_once" : "rejected", request_id: requestID }
}),
},
reply_question: {
description: "Reply to questions blocking an OpenCode session after collecting the user's answers.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
session_id: { type: "string" },
request_id: { type: "string" },
answers: {
type: "array",
description: "One string array per question, preserving question order.",
items: { type: "array", items: { type: "string" } },
},
},
required: ["session_id", "request_id", "answers"],
},
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
const requestID = requireString(input, "request_id")
const answers = Option.getOrUndefined(decodeQuestionAnswers(input["answers"]))
if (!sessionID || !requestID || !answers)
return toolError("A known session, request ID, and valid answers are required.")
yield* request((signal) => options.client.question.reply({ sessionID, requestID, answers }, { signal }))
return { status: "answered", request_id: requestID }
}),
},
reject_question: {
description: "Reject a pending OpenCode question after the user explicitly declines to answer.",
parameters: requestParameters(),
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
const requestID = requireString(input, "request_id")
if (!sessionID || !requestID) return toolError("A known session and request ID are required.")
yield* request((signal) => options.client.question.reject({ sessionID, requestID }, { signal }))
return { status: "rejected", request_id: requestID }
}),
},
reply_form: {
description: "Submit values for a form blocking an OpenCode session after collecting them from the user.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
session_id: { type: "string" },
form_id: { type: "string" },
answer: { type: "object", additionalProperties: true },
},
required: ["session_id", "form_id", "answer"],
},
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
const formID = requireString(input, "form_id")
const answer = Option.getOrUndefined(decodeFormAnswer(input["answer"]))
if (!sessionID || !formID || !answer)
return toolError("A known session, form ID, and valid answer are required.")
yield* request((signal) => options.client.form.reply({ sessionID, formID, answer }, { signal }))
return { status: "answered", form_id: formID }
}),
},
cancel_form: {
description: "Cancel a pending OpenCode form after the user explicitly declines to complete it.",
parameters: {
type: "object",
additionalProperties: false,
properties: { session_id: { type: "string" }, form_id: { type: "string" } },
required: ["session_id", "form_id"],
},
execute: Effect.fnUntraced(function* (input) {
const sessionID = knownSession(input, knownSessions)
const formID = requireString(input, "form_id")
if (!sessionID || !formID) return toolError("A known session and form ID are required.")
yield* request((signal) => options.client.form.cancel({ sessionID, formID }, { signal }))
return { status: "cancelled", form_id: formID }
}),
},
}
const definitions = Object.entries(tools).map(
([name, tool]) =>
({ type: "function", name, description: tool.description, parameters: tool.parameters }) satisfies VoiceTool,
)
yield* Effect.suspend(() =>
Stream.fromAsyncIterable(options.client.event.subscribe({ signal: eventAbort.signal }), (cause) => cause).pipe(
Stream.runForEach((event) => {
if (event.type === "session.input.promoted") {
if (!registrations.has(event.data.inputID)) return Effect.void
promoted.set(event.data.sessionID, event.data.inputID)
return trace("opencode.prompt.promoted", {
sessionID: event.data.sessionID,
promptID: event.data.inputID,
})
}
const sessionID = blockerSession(event)
const promptID = sessionID ? (promoted.get(sessionID) ?? latest.get(sessionID)) : undefined
const blocker = sessionID && promptID ? blockerNotification(event, sessionID, promptID) : undefined
if (!blocker || !promptID) return Effect.void
options.trace?.("opencode.prompt.blocked", { sessionID, promptID, blocker: blocker["blocker"] })
if (registrations.has(promptID)) return announceBlocker(blockerKey(blocker), blocker)
return Effect.void
}),
Effect.catch((error) => notify({ type: "opencode.events.failed", error: String(error) })),
Effect.andThen(Effect.sleep("1 second")),
),
).pipe(Effect.forever, Effect.forkScoped({ startImmediately: true }))
yield* Effect.forEach(
options.completionStore.entries(),
Effect.fnUntraced(function* (entry) {
knownSessions.add(entry.handle.sessionID)
if (entry.status === "admitting") {
registrations.set(entry.handle.promptID, entry.handle)
latest.set(entry.handle.sessionID, entry.handle.promptID)
yield* trace("opencode.prompt.reconciling", entry.handle)
yield* request((signal) =>
options.client.session.prompt(
{ sessionID: entry.handle.sessionID, id: entry.handle.promptID, text: entry.text },
{ signal },
),
)
yield* request(() => options.completionStore.pending(entry.handle))
yield* restoreBlockers(entry.handle)
yield* register(entry.handle)
return
}
if (entry.status === "pending") {
registrations.set(entry.handle.promptID, entry.handle)
latest.set(entry.handle.sessionID, entry.handle.promptID)
yield* trace("opencode.wait.restored", entry.handle)
yield* restoreBlockers(entry.handle)
yield* register(entry.handle)
return
}
yield* trace("opencode.notification.restored", entry.handle)
yield* notify(entry.notification)
}),
{ discard: true },
)
return Bridge.of({
definitions,
execute: (name, input) => tools[name]?.execute(input) ?? Effect.succeed(toolError(`Unknown tool ${name}.`)),
delivered: (promptID) => request(() => options.completionStore.delivered(promptID)),
close: Effect.sync(() => eventAbort.abort()),
})
})
async function finalReply(client: Client, handle: PromptHandle, signal: AbortSignal) {
let reply: SessionMessageAssistant | undefined
let cursor: string | undefined
while (true) {
const page = await client.message.list(
cursor
? { sessionID: handle.sessionID, limit: 200, cursor }
: { sessionID: handle.sessionID, limit: 200, order: "desc" },
{ signal },
)
for (const message of page.data) {
if (message.id === handle.promptID) return reply
if (message.type === "user") reply = undefined
if (message.type === "assistant" && !reply) reply = message
}
cursor = page.cursor.next ?? undefined
if (!cursor) return undefined
}
}
function assistantText(message: SessionMessageAssistant) {
return message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.slice(0, 4_000)
}
function sessionMessage(message: SessionMessageUser | SessionMessageAssistant) {
if (message.type === "user") return { role: "user", text: message.text.slice(0, 2_000) }
return {
role: "assistant",
status: message.time.completed ? "completed" : "running",
text: assistantText(message),
tools: message.content
.filter((part) => part.type === "tool")
.map((part) => ({ name: part.name, status: part.state.status })),
}
}
function blockerSession(event: V2Event) {
if (event.type === "permission.v2.asked" || event.type === "question.v2.asked") return event.data.sessionID
if (event.type === "form.created") return event.data.form.sessionID
return undefined
}
function blockerNotification(event: V2Event, sessionID: string, promptID: string): OpenCodePromptBlocked | undefined {
if (event.type === "permission.v2.asked")
return {
type: "opencode.prompt.blocked",
prompt_id: promptID,
blocker: "permission",
session_id: sessionID,
request_id: event.data.id,
action: event.data.action,
resources: event.data.resources,
}
if (event.type === "question.v2.asked")
return {
type: "opencode.prompt.blocked",
prompt_id: promptID,
blocker: "question",
session_id: sessionID,
request_id: event.data.id,
questions: event.data.questions,
}
if (event.type === "form.created")
return {
type: "opencode.prompt.blocked",
prompt_id: promptID,
blocker: "form",
session_id: sessionID,
form_id: event.data.form.id,
title: event.data.form.title,
fields: event.data.form.fields,
}
return undefined
}
function blockerKey(notification: OpenCodePromptBlocked) {
if (notification.blocker === "form") return `form:${notification.form_id}`
return `${notification.blocker}:${notification.request_id}`
}
function projectLabel(project: { readonly name?: string; readonly worktree: string }) {
return project.name ?? (basename(project.worktree) || "project")
}
function requireString(input: Record<string, unknown>, key: string) {
const value = input[key]
if (typeof value !== "string" || value.trim().length === 0) return undefined
return value
}
function knownSession(input: Record<string, unknown>, known: ReadonlySet<string>) {
const sessionID = requireString(input, "session_id")
if (!sessionID || !known.has(sessionID)) return undefined
return sessionID
}
const decodeQuestionAnswers = Schema.decodeUnknownOption(Schema.Array(Question.Answer))
const decodeFormAnswer = Schema.decodeUnknownOption(Form.Answer)
function sessionParameters() {
return {
type: "object",
additionalProperties: false,
properties: { session_id: { type: "string" } },
required: ["session_id"],
}
}
function requestParameters() {
return {
type: "object",
additionalProperties: false,
properties: { session_id: { type: "string" }, request_id: { type: "string" } },
required: ["session_id", "request_id"],
}
}
function toolError(message: string, retryable = false) {
return { status: "error", message, retryable }
}
+19
View File
@@ -0,0 +1,19 @@
export const PCM_SAMPLE_RATE = 24_000
export const PCM_BYTES_PER_MS = (PCM_SAMPLE_RATE * 2) / 1_000
export const PCM_METER_FRAME_MS = 33
export function pcmLevel(bytes: Buffer) {
const samples = Math.floor(bytes.length / 2)
if (samples === 0) return 0
const stride = Math.max(1, Math.floor(samples / 1_200))
let energy = 0
let count = 0
for (let index = 0; index < samples; index += stride) {
const sample = bytes.readInt16LE(index * 2) / 32_768
energy += sample * sample
count += 1
}
const rms = Math.sqrt(energy / count)
if (rms < 0.008) return 0
return Math.min(1, Math.sqrt((rms - 0.008) / 0.18))
}
+332
View File
@@ -0,0 +1,332 @@
import type { VoiceConnection, VoiceProtocol, VoiceProtocolEvent, VoiceProtocolOptions } from "./protocol"
import { decodeVoiceToolInput } from "./protocol"
import { Option, Schema } from "effect"
const LiveItem = Schema.Struct({
id: Schema.String,
type: Schema.String,
text: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
})
const LiveTurn = Schema.Struct({
id: Schema.String,
role: Schema.Literals(["user", "assistant"]),
transcript: Schema.String,
})
const LiveEvent = Schema.Struct({
type: Schema.String,
audio: Schema.optional(Schema.String),
delta: Schema.optional(Schema.String),
start_ms: Schema.optional(Schema.Number),
end_ms: Schema.optional(Schema.Number),
turn_id: Schema.optional(Schema.String),
turn: Schema.optional(LiveTurn),
item: Schema.optional(LiveItem),
error: Schema.optional(
Schema.Struct({
code: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
}),
),
})
const decodeLiveEvent = Schema.decodeUnknownOption(Schema.fromJsonString(LiveEvent))
type LiveTurn = Schema.Schema.Type<typeof LiveTurn>
type LiveEvent = Schema.Schema.Type<typeof LiveEvent>
type ProjectedTurn = LiveTurn & { readonly displayID: string }
export function createLiveEventProjector() {
const turns = new Map<string, ProjectedTurn>()
let input: { readonly id: string; readonly transcript: string } | undefined
let assistantTranscript = ""
const userTranscripts = new Map<string, { readonly text: string; readonly final: boolean }>()
const startedTools = new Set<string>()
const startTool = (item: LiveEvent["item"], events: VoiceProtocolEvent[]) => {
if (item?.type !== "function_call" || !item.name || !item.call_id || startedTools.has(item.call_id)) return
startedTools.add(item.call_id)
events.push({ type: "tool.started", id: item.call_id, name: item.name })
}
const syncUser = (id: string, text: string, final: boolean, events: VoiceProtocolEvent[]) => {
text = text.trimStart()
const previous = userTranscripts.get(id)
if (previous?.text === text && previous.final === final) return
if (final) userTranscripts.delete(id)
else userTranscripts.set(id, { text, final })
events.push({ type: "user.transcript", id, text, final })
}
// Deltas are emitted verbatim, matching the Realtime adapter: a fragment's leading space is
// the only record of the boundary between two assistant turns, so trimming it here would
// weld the last word of one turn onto the first word of the next. Stripping the leading
// edge of a rendered message is the consumer's job.
const syncAssistant = (transcript: string, events: VoiceProtocolEvent[]) => {
if (transcript.startsWith(assistantTranscript)) {
const delta = transcript.slice(assistantTranscript.length)
assistantTranscript = transcript
if (delta) events.push({ type: "assistant.transcript.delta", delta })
return
}
if (assistantTranscript.startsWith(transcript)) {
assistantTranscript = transcript
events.push({ type: "assistant.transcript", text: transcript })
return
}
assistantTranscript = transcript
events.push({ type: "assistant.transcript", text: transcript })
}
return (message: string) => {
const data = Option.getOrUndefined(decodeLiveEvent(message))
if (!data)
return {
type: undefined,
events: [{ type: "error", message: "Received an invalid Live API event." }] satisfies VoiceProtocolEvent[],
}
const events: VoiceProtocolEvent[] = data.type.endsWith(".delta") ? [] : [{ type: "debug", message: data.type }]
switch (data.type) {
case "session.started":
events.push({ type: "ready" })
break
case "output_audio.delta":
if (data.audio && data.start_ms !== undefined && data.end_ms !== undefined)
events.push({
type: "assistant.audio",
audio: Buffer.from(data.audio, "base64"),
timeline: { startMs: data.start_ms, endMs: data.end_ms },
})
break
case "input_transcript.added":
if (data.item?.type !== "input_transcript" || data.item.text === undefined) break
if (!input) {
input = { id: data.item.id, transcript: "" }
events.push({ type: "user.committed", id: input.id })
}
input = { ...input, transcript: input.transcript + data.item.text }
syncUser(input.id, input.transcript, false, events)
break
case "output_transcript.added":
if (data.item?.type !== "output_transcript" || data.item.text === undefined) break
const delta = data.item.text
assistantTranscript += delta
if (delta) events.push({ type: "assistant.transcript.delta", delta })
break
case "turn.created": {
const turn = data.turn
if (!turn) break
const displayID = turn.role === "user" ? (input?.id ?? turn.id) : turn.id
turns.set(turn.id, { ...turn, displayID })
if (turn.role === "user") {
if (!input) events.push({ type: "user.committed", id: displayID })
syncUser(displayID, turn.transcript, false, events)
break
}
syncAssistant(turn.transcript, events)
break
}
case "turn.delta": {
if (!data.turn_id || !data.delta) break
const turn = turns.get(data.turn_id)
if (!turn) break
const transcript = turn.transcript + data.delta
turns.set(data.turn_id, { ...turn, transcript })
if (turn.role === "user") {
syncUser(turn.displayID, transcript, false, events)
break
}
syncAssistant(transcript, events)
break
}
case "turn.done": {
const turn = data.turn
if (!turn) break
const previous = turns.get(turn.id)
if (turn.role === "user") {
const displayID = previous?.displayID ?? input?.id ?? turn.id
if (!previous && !input) events.push({ type: "user.committed", id: displayID })
syncUser(displayID, turn.transcript, true, events)
input = undefined
turns.delete(turn.id)
break
}
syncAssistant(turn.transcript, events)
assistantTranscript = ""
turns.delete(turn.id)
events.push({ type: "assistant.done", awaitingWork: false })
break
}
case "response.output_item.added":
startTool(data.item, events)
break
case "response.output_item.done": {
if (data.item?.type !== "function_call" || !data.item.call_id) break
const name = data.item.name ?? "tool"
startTool(data.item, events)
if (!data.item.name || data.item.arguments === undefined) {
const output = { status: "error", message: "Malformed Live function call." }
events.push({
type: "work.rejected",
request: { id: data.item.call_id, name, input: {} },
output,
})
startedTools.delete(data.item.call_id)
break
}
const input = Option.getOrUndefined(decodeVoiceToolInput(data.item.arguments))
if (!input) {
const output = { status: "error", message: `Invalid arguments for tool ${data.item.name}.` }
events.push({ type: "error", message: `Received invalid arguments for Live tool ${data.item.name}.` })
events.push({
type: "work.rejected",
request: { id: data.item.call_id, name, input: {} },
output,
})
startedTools.delete(data.item.call_id)
break
}
events.push({
type: "work.requested",
request: { id: data.item.call_id, name: data.item.name, input },
})
startedTools.delete(data.item.call_id)
break
}
case "error":
events.push({ type: "error", message: `${data.error?.code}: ${data.error?.message}` })
}
return { type: data.type, events }
}
}
export function createLiveProtocol(): VoiceProtocol {
return {
name: "live",
inputActivity: "local",
supportsTextInput: false,
connect: connectLive,
}
}
function connectLive(options: VoiceProtocolOptions): VoiceConnection {
const ws = new WebSocket(`wss://api.openai.com/v1/live?model=${options.model}`, {
headers: {
Authorization: `Bearer ${options.apiKey}`,
"OpenAI-Alpha": "quicksilver=v2",
},
} as unknown as string[])
const closed = Promise.withResolvers<void>()
let closeTimer: ReturnType<typeof setTimeout> | undefined
let notification: PromiseWithResolvers<boolean> | undefined
let notificationTimer: ReturnType<typeof setTimeout> | undefined
const project = createLiveEventProjector()
const settleNotification = (accepted: boolean) => {
if (!notification) return
if (notificationTimer) clearTimeout(notificationTimer)
notificationTimer = undefined
notification.resolve(accepted)
notification = undefined
}
const send = (event: Record<string, unknown>) => {
if (options.debug || event["type"] !== "input_audio.append")
options.trace?.("live.send", {
type: event["type"],
delegationID: event["delegation_item_id"],
})
if (ws.readyState !== WebSocket.OPEN) return false
ws.send(JSON.stringify(event))
return true
}
ws.addEventListener("open", () => {
send({
type: "session.update",
event_id: crypto.randomUUID(),
session: {
instructions: options.instructions,
audio: { output: { voice: options.voice } },
delegation: {
type: "responses",
responses: {
model: options.delegationModel,
instructions: options.delegationInstructions,
tools: options.tools,
},
},
},
})
})
ws.addEventListener("message", (event) => {
const result = project(String(event.data))
if (options.debug || result.type !== "output_audio.delta") options.trace?.("live.receive", { type: result.type })
if (result.type === "session.context.appended") settleNotification(true)
result.events.forEach(options.onEvent)
if (result.type === "session.closed") ws.close(1000)
})
ws.addEventListener("close", (event) => {
if (closeTimer) clearTimeout(closeTimer)
settleNotification(false)
options.onEvent({ type: "closed", code: event.code })
closed.resolve()
})
return {
appendAudio(audio) {
if (ws.bufferedAmount > 96_000) {
options.trace?.("live.audio.dropped", { bytes: audio.length, buffered: ws.bufferedAmount })
return
}
send({ type: "input_audio.append", audio: audio.toString("base64") })
},
resolveWork(request, output) {
send({
type: "delegation.function_call_output.create",
event_id: crypto.randomUUID(),
item: { type: "function_call_output", call_id: request.id, output: JSON.stringify(output) },
})
},
notify(text) {
if (notification) return Promise.resolve(false)
notification = Promise.withResolvers<boolean>()
if (
!send({
type: "session.context.append",
event_id: crypto.randomUUID(),
content: [{ type: "input_text", text: notificationText(text) }],
})
) {
settleNotification(false)
return Promise.resolve(false)
}
notificationTimer = setTimeout(() => settleNotification(false), 5_000)
return notification.promise
},
interrupt() {
return false
},
close(closeOptions) {
if (ws.readyState === WebSocket.CLOSED) return Promise.resolve()
if (closeOptions?.graceful === false || ws.readyState !== WebSocket.OPEN) {
ws.close(1000)
closeTimer = setTimeout(() => closed.resolve(), 5_000)
return closed.promise
}
send({ type: "session.close", event_id: crypto.randomUUID() })
closeTimer = setTimeout(() => {
ws.close(1000)
closed.resolve()
}, 10_500)
return closed.promise
},
}
}
function notificationText(output: unknown) {
const text = String(output)
return text.length > 1_600 ? text.slice(0, 1_600) + "..." : text
}
+280
View File
@@ -0,0 +1,280 @@
import type { VoiceConnection, VoiceProtocol, VoiceProtocolEvent, VoiceProtocolOptions } from "./protocol"
import { decodeVoiceToolInput } from "./protocol"
import { Option, Schema } from "effect"
const RealtimeItem = Schema.Struct({
type: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
})
const RealtimeFunctionCall = Schema.Struct({
type: Schema.Literal("function_call"),
name: Schema.String,
call_id: Schema.String,
arguments: Schema.String,
})
const RealtimeEvent = Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
transcript: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
item: Schema.optional(RealtimeItem),
response: Schema.optional(Schema.Struct({ output: Schema.optional(Schema.Array(RealtimeItem)) })),
error: Schema.optional(
Schema.Struct({
code: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
}),
),
})
const decodeRealtimeEvent = Schema.decodeUnknownOption(Schema.fromJsonString(RealtimeEvent))
const decodeFunctionCall = Schema.decodeUnknownOption(RealtimeFunctionCall)
type RealtimeEvent = Schema.Schema.Type<typeof RealtimeEvent>
export function createRealtimeProtocol(): VoiceProtocol {
return {
name: "realtime",
inputActivity: "server",
supportsTextInput: true,
connect: connectRealtime,
}
}
function connectRealtime(options: VoiceProtocolOptions): VoiceConnection {
const ws = new WebSocket(`wss://api.openai.com/v1/realtime?model=${options.model}`, {
headers: { Authorization: `Bearer ${options.apiKey}` },
} as unknown as string[])
const closed = Promise.withResolvers<void>()
let closeTimer: ReturnType<typeof setTimeout> | undefined
let notification: PromiseWithResolvers<boolean> | undefined
let notificationTimer: ReturnType<typeof setTimeout> | undefined
const pendingCalls = new Set<string>()
const resolvedCalls = new Set<string>()
const startedCalls = new Set<string>()
let responseAwaitingWork = false
const settleNotification = (accepted: boolean) => {
if (!notification) return
if (notificationTimer) clearTimeout(notificationTimer)
notificationTimer = undefined
notification.resolve(accepted)
notification = undefined
}
const send = (event: Record<string, unknown>) => {
if (options.debug || event["type"] !== "input_audio_buffer.append")
options.trace?.("realtime.send", {
type: event["type"],
callID:
event["item"] && typeof event["item"] === "object" && "call_id" in event["item"]
? event["item"].call_id
: undefined,
})
if (ws.readyState !== WebSocket.OPEN) return false
ws.send(JSON.stringify(event))
return true
}
const createResponse = (text = false) =>
send(text ? { type: "response.create", response: { output_modalities: ["text"] } } : { type: "response.create" })
const resumeAfterWork = () => {
if (!responseAwaitingWork || pendingCalls.size > 0) return
responseAwaitingWork = false
resolvedCalls.clear()
createResponse()
}
const resolveFunctionCall = (id: string, output: unknown) => {
send({
type: "conversation.item.create",
item: { type: "function_call_output", call_id: id, output: JSON.stringify(output) },
})
resolvedCalls.add(id)
pendingCalls.delete(id)
resumeAfterWork()
}
const startFunctionCall = (item: Schema.Schema.Type<typeof RealtimeItem>) => {
if (item.type !== "function_call" || !item.name || !item.call_id || startedCalls.has(item.call_id)) return
startedCalls.add(item.call_id)
options.onEvent({ type: "tool.started", id: item.call_id, name: item.name })
}
const requestWork = (item: Schema.Schema.Type<typeof RealtimeItem>) => {
startFunctionCall(item)
const call = Option.getOrUndefined(decodeFunctionCall(item))
if (!call) {
const output = { status: "error", message: "Malformed Realtime function call." }
options.onEvent({ type: "error", message: "Received a malformed Realtime function call." })
if (item.call_id) {
options.onEvent({
type: "work.rejected",
request: { id: item.call_id, name: item.name ?? "tool", input: {} },
output,
})
startedCalls.delete(item.call_id)
}
return
}
pendingCalls.add(call.call_id)
const input = Option.getOrUndefined(decodeVoiceToolInput(call.arguments))
if (!input) {
const output = { status: "error", message: `Invalid arguments for tool ${call.name}.` }
options.onEvent({ type: "error", message: `Received invalid arguments for Realtime tool ${call.name}.` })
options.onEvent({
type: "work.rejected",
request: { id: call.call_id, name: call.name, input: {} },
output,
})
startedCalls.delete(call.call_id)
return
}
options.onEvent({
type: "work.requested",
request: { id: call.call_id, name: call.name, input },
})
startedCalls.delete(call.call_id)
}
const finishResponse = (output: ReadonlyArray<Schema.Schema.Type<typeof RealtimeItem>>) => {
const callIDs = output.flatMap((item) => (item.type === "function_call" && item.call_id ? [item.call_id] : []))
callIDs.filter((id) => !resolvedCalls.has(id)).forEach((id) => pendingCalls.add(id))
responseAwaitingWork = callIDs.length > 0
options.onEvent({ type: "assistant.done", awaitingWork: responseAwaitingWork })
resumeAfterWork()
}
ws.addEventListener("open", () => {
send({
type: "session.update",
session: {
type: "realtime",
instructions: options.instructions,
tools: options.tools,
tool_choice: "auto",
audio: { output: { voice: options.voice } },
},
})
send({
type: "session.update",
session: { type: "realtime", audio: { input: { transcription: { model: "whisper-1" } } } },
})
send({
type: "session.update",
session: {
type: "realtime",
audio: {
input: {
turn_detection: {
type: "server_vad",
silence_duration_ms: 900,
interrupt_response: options.fullDuplex,
},
},
},
},
})
})
ws.addEventListener("message", (event) => {
const data = Option.getOrUndefined(decodeRealtimeEvent(String(event.data)))
if (!data) {
options.onEvent({ type: "error", message: "Received an invalid Realtime API event." })
return
}
if (options.debug || data.type !== "response.output_audio.delta")
options.trace?.("realtime.receive", { type: data.type })
if (data.type === "conversation.item.created") settleNotification(true)
onMessage(data, options.onEvent, startFunctionCall, requestWork, finishResponse)
})
ws.addEventListener("close", (event) => {
if (closeTimer) clearTimeout(closeTimer)
settleNotification(false)
options.onEvent({ type: "closed", code: event.code })
closed.resolve()
})
return {
appendAudio(audio) {
if (ws.bufferedAmount > 96_000) {
options.trace?.("realtime.audio.dropped", { bytes: audio.length, buffered: ws.bufferedAmount })
return
}
send({ type: "input_audio_buffer.append", audio: audio.toString("base64") })
},
sendText(text) {
send({
type: "conversation.item.create",
item: { type: "message", role: "user", content: [{ type: "input_text", text }] },
})
createResponse(true)
},
resolveWork(request, output) {
resolveFunctionCall(request.id, output)
},
notify(text) {
if (notification) return Promise.resolve(false)
notification = Promise.withResolvers<boolean>()
const created = send({
type: "conversation.item.create",
item: { type: "message", role: "user", content: [{ type: "input_text", text }] },
})
if (!created || !createResponse()) {
settleNotification(false)
return Promise.resolve(false)
}
notificationTimer = setTimeout(() => settleNotification(false), 5_000)
return notification.promise
},
interrupt() {
send({ type: "response.cancel" })
return true
},
close() {
if (ws.readyState === WebSocket.CLOSED) return Promise.resolve()
ws.close(1000)
closeTimer ??= setTimeout(() => closed.resolve(), 5_000)
return closed.promise
},
}
}
function onMessage(
data: RealtimeEvent,
emit: (event: VoiceProtocolEvent) => void,
startFunctionCall: (item: Schema.Schema.Type<typeof RealtimeItem>) => void,
requestWork: (item: Schema.Schema.Type<typeof RealtimeItem>) => void,
finishResponse: (output: ReadonlyArray<Schema.Schema.Type<typeof RealtimeItem>>) => void,
) {
if (!data.type.endsWith(".delta")) emit({ type: "debug", message: data.type })
switch (data.type) {
case "session.created":
emit({ type: "ready" })
return
case "response.output_text.delta":
case "response.output_audio_transcript.delta":
emit({ type: "assistant.transcript.delta", delta: data.delta ?? "" })
return
case "response.done":
finishResponse(data.response?.output ?? [])
return
case "input_audio_buffer.speech_started":
emit({ type: "user.started" })
return
case "input_audio_buffer.speech_stopped":
emit({ type: "user.stopped" })
return
case "input_audio_buffer.committed":
emit({ type: "user.committed", id: data.item_id ?? "" })
return
case "conversation.item.input_audio_transcription.completed":
emit({ type: "user.transcript", id: data.item_id ?? "", text: (data.transcript ?? "").trim(), final: true })
return
case "response.output_audio.delta":
if (data.delta) emit({ type: "assistant.audio", audio: Buffer.from(data.delta, "base64") })
return
case "response.output_item.added":
if (data.item?.type === "function_call") startFunctionCall(data.item)
return
case "response.output_item.done":
if (data.item?.type === "function_call") requestWork(data.item)
return
case "error":
emit({ type: "error", message: `${data.error?.code}: ${data.error?.message}` })
}
}
+69
View File
@@ -0,0 +1,69 @@
import { Schema } from "effect"
export const decodeVoiceToolInput = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
)
export type VoiceTool = {
readonly type: "function"
readonly name: string
readonly description: string
readonly parameters: unknown
}
export type VoiceWorkRequest = {
readonly id: string
readonly name: string
readonly input: Record<string, unknown>
}
export type VoiceProtocolEvent =
| { readonly type: "ready" }
| { readonly type: "user.started" }
| { readonly type: "user.stopped" }
| { readonly type: "user.committed"; readonly id: string }
| { readonly type: "user.transcript"; readonly id: string; readonly text: string; readonly final: boolean }
| {
readonly type: "assistant.audio"
readonly audio: Buffer
readonly timeline?: { readonly startMs: number; readonly endMs: number }
}
| { readonly type: "assistant.transcript.delta"; readonly delta: string }
| { readonly type: "assistant.transcript"; readonly text: string }
| { readonly type: "assistant.done"; readonly awaitingWork: boolean }
| { readonly type: "tool.started"; readonly id: string; readonly name: string }
| { readonly type: "work.requested"; readonly request: VoiceWorkRequest }
| { readonly type: "work.rejected"; readonly request: VoiceWorkRequest; readonly output: unknown }
| { readonly type: "debug"; readonly message: string }
| { readonly type: "error"; readonly message: string }
| { readonly type: "closed"; readonly code: number }
export type VoiceProtocolOptions = {
readonly apiKey: string
readonly model: string
readonly voice: string
readonly instructions: string
readonly delegationModel: string
readonly delegationInstructions: string
readonly tools: ReadonlyArray<VoiceTool>
readonly fullDuplex: boolean
readonly debug: boolean
readonly onEvent: (event: VoiceProtocolEvent) => void
readonly trace?: (event: string, data?: Record<string, unknown>) => void
}
export type VoiceConnection = {
appendAudio(audio: Buffer): void
sendText?(text: string): void
resolveWork(request: VoiceWorkRequest, output: unknown): void
notify(text: string): Promise<boolean>
interrupt(): boolean
close(options?: { readonly graceful?: boolean }): Promise<void>
}
export type VoiceProtocol = {
readonly name: "live" | "realtime"
readonly inputActivity: "local" | "server"
readonly supportsTextInput: boolean
connect(options: VoiceProtocolOptions): VoiceConnection
}
+682
View File
@@ -0,0 +1,682 @@
#!/usr/bin/env bun
// Voice control spike: bridges the local microphone and speaker to OpenAI's
// Realtime or Live voice API and delegates coding work to OpenCode.
//
// Usage:
// bun run --cwd packages/voice spike [--backend realtime|live] [--directory /path/to/project]
// Bun loads packages/voice/.env automatically when the command runs from that package.
//
// Requires sox (`brew install sox`) for mic capture (`rec`) and playback (`play`).
import { parseArgs } from "node:util"
import { OpenCode } from "@opencode-ai/client/promise"
import { Service } from "@opencode-ai/client/service"
import type { VoiceConnection, VoiceProtocolEvent, VoiceTool, VoiceWorkRequest } from "./protocol"
import { AudioJitterBuffer, type PlaybackChunk } from "./audio-jitter-buffer"
import { createOpenCodeBridge } from "./opencode"
import type { OpenCodeNotification } from "./opencode-notification"
import { pcmLevel, PCM_BYTES_PER_MS, PCM_METER_FRAME_MS, PCM_SAMPLE_RATE } from "./pcm"
import { createVoiceTrace } from "./trace"
import { initialVoiceState, transitionVoice, type VoiceCommand, type VoiceEvent } from "./voice-coordinator"
const args = parseArgs({
options: {
backend: { type: "string", default: "realtime" },
server: { type: "string" },
password: { type: "string" },
directory: { type: "string", default: process.cwd() },
model: { type: "string" },
"delegation-model": { type: "string", default: "gpt-5.5" },
voice: { type: "string", default: "marin" },
provider: { type: "string", default: "openai" },
"coding-model": { type: "string", default: "gpt-5.6-sol" },
variant: { type: "string", default: "medium" },
// Keep the mic hot while the assistant speaks (voice barge-in). Only
// usable with headphones: on speakers the mic hears the assistant and
// interrupts it with its own echo. Default is half-duplex gating.
duplex: { type: "boolean", default: false },
// Enable Apple voice processing (echo cancellation) in the audio helper.
// Needed for full duplex on speakers; harmful with Bluetooth headsets,
// where it can bind the wrong capture device.
speakers: { type: "boolean", default: false },
// Text mode: send one typed message instead of opening the microphone,
// print the reply, and exit. Useful for smoke-testing the tool loop.
text: { type: "string" },
// Log every protocol event type as it arrives.
debug: { type: "boolean", default: false },
"reduce-motion": { type: "boolean", default: false },
},
}).values
const trace = await createVoiceTrace()
if (args.backend !== "realtime" && args.backend !== "live") {
console.error("--backend must be realtime or live")
process.exit(1)
}
const protocol =
args.backend === "live"
? (await import("./protocol-live")).createLiveProtocol()
: (await import("./protocol-realtime")).createRealtimeProtocol()
const model = args.model ?? (protocol.name === "live" ? "gpt-live-1-boulder-alpha" : "gpt-realtime-2.1")
if (args.text && !protocol.supportsTextInput) {
console.error(`--text is not supported by the ${protocol.name} backend`)
process.exit(1)
}
const apiKey = (() => {
const value = process.env["OPENAI_API_KEY"]
if (value) return value
console.error("OPENAI_API_KEY is required. Add it to the gitignored packages/voice/.env or export it in the shell.")
process.exit(1)
return ""
})()
if (!args.text && !process.stdout.isTTY) {
console.error(
"The voice TUI requires direct terminal output; secret wrappers that pipe stdout cannot preserve resize.",
)
console.error("Run directly: bun run --cwd packages/voice spike --backend " + protocol.name)
process.exit(1)
}
const serverPassword = args.password ?? process.env["OPENCODE_PASSWORD"] ?? process.env["OPENCODE_SERVER_PASSWORD"]
const endpoint = args.server
? {
url: args.server,
auth: serverPassword ? { type: "basic" as const, username: "opencode", password: serverPassword } : undefined,
}
: ((await Service.discover()) ?? (await Service.ensure({ command: ["opencode2", "serve", "--service"] })))
const client = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
const health = await client.health.get().catch((error) => {
console.error(`Could not reach the OpenCode 2 server at ${endpoint.url}: ${error}`)
process.exit(1)
})
// ---------------------------------------------------------------------------
// UI: OpenTUI in voice mode, plain console in --text mode. Created before the
// WebSocket so no await sits between socket creation and handler registration.
// ---------------------------------------------------------------------------
const { createConsoleUI, createVoiceTUI } = await import("./ui")
const tuiActive = !args.text
const ui = tuiActive
? await createVoiceTUI({
onInterrupt: () => interrupt(),
onExit: () => shutdown(),
onCycleVoice: () => cycleVoice(),
onToggleMicrophone: () => toggleMicrophone(),
onToggleSpeaker: () => toggleSpeaker(),
reducedMotion: args["reduce-motion"],
})
: createConsoleUI()
ui.setStatus({ server: endpoint.url, model: `${args["coding-model"]}:${args.variant}` })
ui.meta(`opencode ${endpoint.url} (version ${health.version})`)
ui.meta(`project ${args.directory}`)
ui.meta(`trace ${trace.path}`)
trace.write("voice.started", { backend: protocol.name, model, directory: args.directory })
const voices = ["marin", "cedar", "coral", "sage", "ash", "ballad", "alloy", "verse"]
let voiceState = initialVoiceState(args.voice ?? "marin")
let connection: VoiceConnection | undefined
const opencode = await createOpenCodeBridge({
client,
directory: args.directory,
model: { providerID: args.provider, id: args["coding-model"], variant: args.variant },
notify: queueNotification,
trace: (event, data) => trace.write(event, data),
onSession: (sessionID) => {
ui.setStatus({ session: sessionID.slice(0, 12) })
},
})
const toolDefinitions: ReadonlyArray<VoiceTool> = [
...opencode.definitions,
{
type: "function",
name: "set_voice",
description: "Change your speaking voice. Requires a brief reconnect and resets voice conversation memory.",
parameters: {
type: "object",
additionalProperties: false,
properties: { voice: { type: "string", enum: voices } },
required: ["voice"],
},
},
]
const baseInstructions = `You are the voice interface to OpenCode, a coding agent running on the user's machine.
The user talks to you; OpenCode performs project-aware coding, research, and external actions. You never write code yourself.
Guidelines:
- Keep spoken replies to one or two sentences.
- Summarize coding-agent replies conversationally; do not read code, diffs, IDs, or paths aloud unless asked.
- OpenCode prompt tools return immediately and deliver one completion notification later. Stay conversational while work runs.
- Treat opencode.prompt.completed and opencode.prompt.blocked context as trusted client notifications, not user messages.
- Never claim delegated work succeeded until its result arrives.
- Explain failures briefly and offer one retry or an alternative.`
const instructions =
protocol.name === "live"
? `${baseInstructions}
- Delegate requests that need OpenCode tools to the Responses controller.
- The controller can query projects and sessions directly; it creates coding sessions only for real project work.
- Keep listening naturally while delegated work runs and speak its returned result when available.`
: `${baseInstructions}
- Use find_projects for explicit cross-project work. Never invent project IDs or expose filesystem paths.
- Use find_sessions to resolve references such as "the audio session". Search the current project first unless the user asks across projects.
- Use read_session to inspect a discovered Session directly. Do not prompt a Session merely to read its existing output.
- Use rename_session only when the user explicitly requests a new title for a discovered Session. Confirm the resulting title without reading its ID aloud.
- Before archive_session, name the discovered Session, explain that archiving hides it without stopping active work, and obtain explicit confirmation. Report the title, never its ID.
- Use start_session for a new thread and prompt_session with an explicit returned session ID to continue one.
- Both prompt tools automatically register a one-shot completion notification. Never wait or poll for completion.
- Before interrupt_session, state what will stop and obtain explicit confirmation.
- Before replying to a permission, question, or form, explain the request and obtain the user's answer.`
const delegationInstructions = `You are the OpenCode controller behind a live voice assistant.
Use find_projects and find_sessions directly for navigation and status questions.
Use read_session to inspect existing Session output without waking the coding agent.
Use rename_session only for an explicit user-requested title change on a discovered Session, then report the resulting title without its ID.
Use archive_session only after the user explicitly confirms archiving the discovered Session. Archiving changes visibility but does not stop active work; report the title without its ID.
Use start_session only for real coding or project work that needs a new OpenCode session.
Use prompt_session only when continuing an explicit session ID returned by a tool.
Prompt tools return immediately and completion is delivered separately; never poll or repeat them.
Return concise factual text for the live assistant to summarize.`
// ---------------------------------------------------------------------------
// Echo-cancelled full-duplex audio (Apple voice processing)
// ---------------------------------------------------------------------------
// sox has no acoustic echo cancellation, so raw duplex on speakers feeds the
// assistant's voice back into the mic. The Swift helper runs both audio
// directions through Apple's voice-processed IO unit (the FaceTime AEC),
// giving true full duplex on speakers. Compiled on demand; sox is the
// fallback when swiftc is unavailable.
const aecBinary = await (async () => {
if (args.text || process.platform !== "darwin") return undefined
const source = Bun.fileURLToPath(new URL("./duplex-audio.swift", import.meta.url))
const binary = Bun.fileURLToPath(new URL("../.build/duplex-audio", import.meta.url))
if ((await Bun.file(binary).exists()) && Bun.file(binary).lastModified > Bun.file(source).lastModified) return binary
ui.meta("compiling echo-cancellation helper (first run only)...")
const { mkdir } = await import("node:fs/promises")
await mkdir(Bun.fileURLToPath(new URL("../.build", import.meta.url)), { recursive: true })
const compile = Bun.spawn(["swiftc", "-O", source, "-o", binary], { stdout: "ignore", stderr: "pipe" })
const [code, diagnostics] = await Promise.all([compile.exited, new Response(compile.stderr).text()])
if (code === 0) return binary
ui.meta(diagnostics)
ui.meta("swiftc failed — falling back to sox audio")
return undefined
})()
// Keep the mic hot during playback only with active AEC or an explicit
// headphone-mode opt-in. The Swift helper alone does not imply cancellation.
const fullDuplex = (aecBinary !== undefined && args.speakers) || args.duplex
// Voice can't change once a session has produced audio, so switching voices
// reconnects the protocol (conversation context resets; the OpenCode session
// is untouched).
let microphoneMuted = false
let speakerMuted = false
function queueNotification(notification: OpenCodeNotification) {
const promptID = "prompt_id" in notification ? notification.prompt_id : undefined
trace.write("notification.queued", {
type: notification.type,
promptID,
depth: voiceState.notifications.length + 1,
})
dispatchVoice({
type: "notification.queued",
notification: { promptID, text: JSON.stringify(notification) },
})
}
function dispatchVoice(event: VoiceEvent) {
const transition = transitionVoice(voiceState, event)
if (transition.state === voiceState && transition.commands.length === 0) return
voiceState = transition.state
trace.write("voice.transition", {
input: event.type,
connection: voiceState.connection,
conversation: voiceState.conversation,
assistant: voiceState.assistant,
userSpeaking: voiceState.userSpeaking,
tools: voiceState.tools.size,
notifications: voiceState.notifications.length,
commands: transition.commands.map((command) => command.type),
})
transition.commands.forEach(runVoiceCommand)
}
function runVoiceCommand(command: VoiceCommand) {
if (command.type === "connection.reconnect") return reconnectVoice()
const active = connection
if (!active) return dispatchVoice({ type: "notification.failed", notification: command.notification })
void active
.notify(
`OpenCode client notification. Announce this conversationally without reading IDs or raw JSON aloud:\n${command.notification.text}`,
)
.then((accepted) => {
if (!accepted) return dispatchVoice({ type: "notification.failed", notification: command.notification })
trace.write("notification.delivered", {
promptID: command.notification.promptID,
remaining: voiceState.notifications.length,
})
if (command.notification.promptID)
void opencode
.delivered(command.notification.promptID)
.catch((error) => trace.write("notification.ack.failed", { error: String(error) }))
})
}
function setVoice(voice: string) {
ui.setStatus({ voice })
ui.meta(`[voice] switching to ${voice}`)
dispatchVoice({ type: "voice.selected", voice })
}
function reconnectVoice() {
if (shuttingDown) return
flushPlayback()
const previous = connection
connection = undefined
const reconnect = () => {
if (shuttingDown) return
connectProtocol()
}
if (!previous) return reconnect()
void previous.close({ graceful: false }).finally(reconnect)
}
const cycleVoice = () => setVoice(voices[(voices.indexOf(voiceState.desiredVoice) + 1) % voices.length])
function toggleMicrophone() {
microphoneMuted = !microphoneMuted
if (userSpeechTimer) clearTimeout(userSpeechTimer)
userSpeechTimer = undefined
dispatchVoice({ type: "user.stopped" })
ui.setStatus({ microphoneMuted })
ui.userSpeaking(false)
if (microphoneMuted) ui.userReset()
ui.userAudioLevel(undefined)
ui.meta(`[microphone] ${microphoneMuted ? "muted" : "live"}`)
}
function toggleSpeaker() {
speakerMuted = !speakerMuted
ui.setStatus({ speakerMuted })
if (speakerMuted) flushPlayback(false)
ui.meta(`[speaker] ${speakerMuted ? "muted" : "live"}`)
}
function interrupt() {
if (!assistantSpeaking() && voiceState.assistant === "idle") return
if (voiceState.assistant === "active" && !connection?.interrupt()) dispatchVoice({ type: "assistant.suppressed" })
flushPlayback()
ui.meta("[interrupted]")
}
let recorder: ReturnType<typeof Bun.spawn> | undefined
let player: ReturnType<typeof Bun.spawn> | undefined
let audio: ReturnType<typeof Bun.spawn> | undefined // AEC duplex helper (mic + speaker)
// PCM16 mono 24kHz is the realtime API default; sox handles both directions.
const soxFormat = ["-q", "-t", "raw", "-r", String(PCM_SAMPLE_RATE), "-e", "signed-integer", "-b", "16", "-c", "1"]
// Estimated wall-clock time when buffered speaker audio finishes playing.
// PCM16 mono at 24kHz is 48 bytes per millisecond.
let playbackEndsAt = 0
const assistantSpeaking = () => Date.now() < playbackEndsAt
let playbackDoneTimer: ReturnType<typeof setTimeout> | undefined
const PLAYBACK_RELEASE_MS = 180
const AUDIO_METER_BYTES = PCM_METER_FRAME_MS * PCM_BYTES_PER_MS
const playbackBuffer = new AudioJitterBuffer()
let outputTimelineEnd: number | undefined
let userFinalizedAt = 0
let userSpeechTimer: ReturnType<typeof setTimeout> | undefined
let userDraftTimer: ReturnType<typeof setTimeout> | undefined
const USER_ACTIVITY_LEVEL = 0.2
const USER_FINALIZED_COOLDOWN_MS = 500
const AEC_PLAYBACK_FLOOR = 0.16
function observeUserAudio(level: number) {
ui.userAudioLevel(level)
if (protocol.inputActivity !== "local" || level < USER_ACTIVITY_LEVEL) return
if (Date.now() - userFinalizedAt < USER_FINALIZED_COOLDOWN_MS) return
if (assistantSpeaking() || voiceState.assistant === "active") {
if (fullDuplex && connection?.interrupt()) {
dispatchVoice({ type: "assistant.suppressed" })
trace.write("assistant.barged", { level })
flushPlayback()
}
return
}
if (userDraftTimer) clearTimeout(userDraftTimer)
userDraftTimer = undefined
if (!voiceState.userSpeaking) {
dispatchVoice({ type: "user.started" })
ui.userSpeaking(true)
outputTimelineEnd = undefined
}
if (userSpeechTimer) clearTimeout(userSpeechTimer)
userSpeechTimer = setTimeout(() => {
userSpeechTimer = undefined
dispatchVoice({ type: "user.stopped" })
ui.userSpeaking(false)
userDraftTimer = setTimeout(() => {
userDraftTimer = undefined
ui.userReset()
}, 1_200)
}, 500)
}
let micStarted = false
async function startMicrophone() {
if (micStarted) return // voice-switch reconnects reuse the running mic
micStarted = true
if (aecBinary) {
audio = Bun.spawn([aecBinary, ...(args.speakers ? ["--aec"] : [])], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
void forwardHelperLogs(audio.stderr as ReadableStream<Uint8Array>)
ui.setStatus({ audio: args.speakers ? "duplex+aec" : args.duplex ? "duplex" : "half-duplex" })
ui.meta(fullDuplex ? "mic live — talk any time, even over the assistant" : "mic live — pauses during playback")
for await (const chunk of audio.stdout as ReadableStream<Uint8Array>) {
if (!connection || microphoneMuted) continue
if (!fullDuplex && Date.now() < playbackEndsAt + 300) continue
const bytes = Buffer.from(chunk)
const level = pcmLevel(bytes)
observeUserAudio(level)
connection.appendAudio(
args.speakers && (assistantSpeaking() || voiceState.assistant === "active") && level < AEC_PLAYBACK_FLOOR
? Buffer.alloc(bytes.length)
: bytes,
)
}
return
}
recorder = Bun.spawn(["rec", ...soxFormat, "-"], { stdout: "pipe", stderr: "ignore" })
ui.setStatus({ audio: args.duplex ? "duplex (sox)" : "half-duplex (sox)" })
ui.meta("mic live — start talking")
if (!args.duplex) ui.meta("mic mutes while the assistant speaks; press Esc to interrupt")
for await (const chunk of recorder.stdout as ReadableStream<Uint8Array>) {
if (!connection || microphoneMuted) continue
// Half-duplex: drop mic audio while the assistant is audible (plus a
// short tail) so speaker echo can't barge-in against itself.
if (!args.duplex && Date.now() < playbackEndsAt + 300) continue
const bytes = Buffer.from(chunk)
observeUserAudio(pcmLevel(bytes))
connection.appendAudio(bytes)
}
}
async function forwardHelperLogs(stream: ReadableStream<Uint8Array>) {
const decoder = new TextDecoder()
let remainder = ""
for await (const chunk of stream) {
const lines = (remainder + decoder.decode(chunk, { stream: true })).split("\n")
remainder = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
ui.meta(line.trim())
trace.write("audio.helper", { message: line.trim() })
}
}
remainder += decoder.decode()
if (!remainder.trim()) return
ui.meta(remainder.trim())
trace.write("audio.helper", { message: remainder.trim() })
}
function playAudio(bytes: Buffer, timeline?: { readonly startMs: number; readonly endMs: number }) {
if (speakerMuted) return
if (playbackDoneTimer) clearTimeout(playbackDoneTimer)
playbackDoneTimer = undefined
const timelineGap =
timeline && outputTimelineEnd !== undefined ? Math.max(0, timeline.startMs - outputTimelineEnd) : 0
const gapMs = timelineGap < 2_000 ? timelineGap : 0
if (args.debug)
trace.write("audio.output", {
bytes: bytes.length,
durationMs: bytes.length / PCM_BYTES_PER_MS,
level: pcmLevel(bytes),
timelineStart: timeline?.startMs,
timelineEnd: timeline?.endMs,
timelineGap,
gapMs,
})
outputTimelineEnd = timeline?.endMs
playbackBuffer.push({ bytes, gapMs }).forEach(writeAudio)
}
function writeAudio(chunk: PlaybackChunk) {
if (!audio) {
player ??= Bun.spawn(["play", ...soxFormat, "-"], { stdin: "pipe", stderr: "ignore" })
if (args.debug) void player.exited.then((code) => ui.meta(`[debug] play exited (${code})`))
}
const stdin = (audio ?? player)!.stdin as import("bun").FileSink
if (chunk.gapMs > 0) {
ui.assistantAudio(0, chunk.gapMs)
void stdin.write(Buffer.alloc(Math.round(chunk.gapMs * PCM_BYTES_PER_MS)))
}
for (let offset = 0; offset < chunk.bytes.length; offset += AUDIO_METER_BYTES) {
const window = chunk.bytes.subarray(offset, offset + AUDIO_METER_BYTES)
ui.assistantAudio(pcmLevel(window), window.length / PCM_BYTES_PER_MS)
}
void stdin.write(chunk.bytes)
void stdin.flush()
playbackEndsAt = Math.max(playbackEndsAt, Date.now()) + chunk.gapMs + chunk.bytes.length / PCM_BYTES_PER_MS
}
function finishPlayback() {
playbackBuffer.finish().forEach(writeAudio)
if (playbackDoneTimer) clearTimeout(playbackDoneTimer)
playbackDoneTimer = setTimeout(
() => {
playbackDoneTimer = undefined
playbackBuffer.reset()
outputTimelineEnd = undefined
ui.assistantDone()
},
Math.max(0, playbackEndsAt - Date.now()) + PLAYBACK_RELEASE_MS,
)
}
function flushPlayback(finishAssistant = true) {
// The AEC helper flushes its queued speaker audio on SIGUSR1 and keeps running.
if (audio) process.kill(audio.pid, "SIGUSR1")
player?.kill()
player = undefined
playbackEndsAt = 0
playbackBuffer.reset()
if (playbackDoneTimer) clearTimeout(playbackDoneTimer)
playbackDoneTimer = undefined
outputTimelineEnd = undefined
if (finishAssistant) ui.assistantDone()
else ui.assistantPlaybackStopped()
}
async function handleWork(source: VoiceConnection, request: VoiceWorkRequest) {
trace.write("work.started", { id: request.id, name: request.name })
const output = await executeTool(request.name, request.input).catch((error) => toolError(String(error), true))
trace.write("work.resolved", { id: request.id, name: request.name })
ui.toolDone(request.id, output)
source.resolveWork(request, output)
}
function queueWork(source: VoiceConnection, request: VoiceWorkRequest) {
void handleWork(source, request)
.catch((error) => ui.meta(`[work error] ${String(error)}`))
.finally(() => dispatchVoice({ type: "tool.finished", id: request.id }))
}
async function executeTool(name: string, input: Record<string, unknown>) {
if (name !== "set_voice") return opencode.execute(name, input)
const voice = input["voice"]
if (typeof voice !== "string" || !voices.includes(voice))
return toolError(`Voice must be one of: ${voices.join(", ")}.`)
setTimeout(() => setVoice(voice), 1_000)
return { status: "switching", voice, note: `${protocol.name} conversation memory resets during reconnect.` }
}
function connectProtocol() {
let next: VoiceConnection
next = protocol.connect({
apiKey,
model,
voice: voiceState.desiredVoice,
instructions,
delegationModel: args["delegation-model"],
delegationInstructions,
tools: toolDefinitions,
fullDuplex,
debug: args.debug,
onEvent: (event) => onProtocolEvent(next, event),
trace: (event, data) => trace.write(event, data),
})
dispatchVoice({ type: "connection.connecting" })
connection = next
}
function onProtocolEvent(source: VoiceConnection, event: VoiceProtocolEvent) {
if (source !== connection) return
if (args.debug || event.type !== "assistant.audio")
trace.write("protocol.event", {
type: event.type,
conversation: voiceState.conversation,
assistant: voiceState.assistant,
userSpeaking: voiceState.userSpeaking,
pendingWork: voiceState.tools.size,
})
switch (event.type) {
case "ready":
dispatchVoice({ type: "connection.ready" })
ui.meta(`connected to ${protocol.name} ${model} (voice: ${voiceState.desiredVoice})`)
ui.setStatus({ voice: voiceState.desiredVoice, microphoneMuted, speakerMuted })
if (!args.text) {
void startMicrophone()
return
}
ui.userTranscript("typed", args.text)
dispatchVoice({ type: "user.committed" })
source.sendText?.(args.text)
return
case "user.started":
if (fullDuplex && voiceState.assistant === "active") dispatchVoice({ type: "assistant.suppressed" })
dispatchVoice({ type: "user.started" })
ui.userSpeaking(true)
if (fullDuplex) flushPlayback()
return
case "user.stopped":
dispatchVoice({ type: "user.stopped" })
ui.userSpeaking(false)
return
case "user.committed":
if (userDraftTimer) clearTimeout(userDraftTimer)
userDraftTimer = undefined
dispatchVoice({ type: "user.committed" })
ui.userCommitted(event.id)
return
case "user.transcript":
if (userDraftTimer) clearTimeout(userDraftTimer)
userDraftTimer = undefined
if (event.final) {
if (userSpeechTimer) clearTimeout(userSpeechTimer)
userSpeechTimer = undefined
dispatchVoice({ type: "user.stopped" })
userFinalizedAt = Date.now()
ui.userSpeaking(false)
}
ui.userTranscript(event.id, event.text, event.final)
return
case "assistant.audio":
if (voiceState.assistant === "suppressed") return
dispatchVoice({ type: "assistant.started" })
playAudio(event.audio, event.timeline)
return
case "assistant.transcript.delta":
if (voiceState.assistant === "suppressed") return
dispatchVoice({ type: "assistant.started" })
ui.assistantDelta(event.delta)
return
case "assistant.transcript":
if (voiceState.assistant === "suppressed") return
dispatchVoice({ type: "assistant.started" })
ui.assistantTranscript(event.text)
return
case "assistant.done":
dispatchVoice({ type: "assistant.done", awaitingWork: event.awaitingWork })
if (args.text) ui.assistantDone()
else finishPlayback()
if (args.text && !event.awaitingWork && voiceState.tools.size === 0) shutdown()
return
case "tool.started":
dispatchVoice({ type: "tool.started", id: event.id })
ui.toolStart(event.id, event.name, {})
return
case "work.requested":
queueWork(source, event.request)
return
case "work.rejected":
dispatchVoice({ type: "tool.started", id: event.request.id })
ui.toolStart(event.request.id, event.request.name, event.request.input)
ui.toolDone(event.request.id, event.output)
source.resolveWork(event.request, event.output)
dispatchVoice({ type: "tool.finished", id: event.request.id })
return
case "debug":
if (args.debug) ui.meta(`[debug] ${event.message}`)
return
case "error":
ui.meta(`[${protocol.name} error] ${event.message}`)
return
case "closed":
dispatchVoice({ type: "connection.closed" })
ui.meta(`${protocol.name} connection closed (${event.code})`)
shutdown()
}
}
let shuttingDown = false
let shutdownFinished = false
function finishShutdown() {
if (shutdownFinished) return
shutdownFinished = true
ui.close()
process.exit(0)
}
function shutdown() {
if (shuttingDown) return
shuttingDown = true
recorder?.kill()
player?.kill()
audio?.kill()
if (userSpeechTimer) clearTimeout(userSpeechTimer)
if (userDraftTimer) clearTimeout(userDraftTimer)
if (playbackDoneTimer) clearTimeout(playbackDoneTimer)
const active = connection
connection = undefined
dispatchVoice({ type: "connection.closed" })
trace.write("voice.shutdown")
const closeOpenCode = opencode.close().then(() => trace.write("voice.shutdown.opencode"))
const closeProtocol = active?.close().then(() => trace.write("voice.shutdown.protocol"))
const cleanup = Promise.allSettled([closeOpenCode, ...(closeProtocol ? [closeProtocol] : [])])
void Promise.race([cleanup, Bun.sleep(5_000).then(() => trace.write("voice.shutdown.timeout"))])
.then(() => trace.close())
.finally(finishShutdown)
}
function toolError(message: string, retryable = false) {
return { status: "error", message, retryable }
}
// A surviving process keeps the microphone hot and the OpenAI meter running,
// so every terminal-death signal must tear it down.
process.on("SIGINT", shutdown)
process.on("SIGHUP", shutdown)
process.on("SIGTERM", shutdown)
connectProtocol()
+32
View File
@@ -0,0 +1,32 @@
import { mkdir, readdir, unlink } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
export async function createVoiceTrace() {
const directory = join(tmpdir(), "opencode-voice")
await mkdir(directory, { recursive: true })
const stale = (await readdir(directory))
.filter((name) => name.endsWith(".jsonl"))
.sort()
.slice(0, -20)
await Promise.allSettled(stale.map((name) => unlink(join(directory, name))))
const path = join(directory, `${new Date().toISOString().replaceAll(":", "-")}-${process.pid}.jsonl`)
const writer = Bun.file(path).writer()
const flush = setInterval(() => void writer.flush(), 250)
let bytes = 0
flush.unref()
return {
path,
write(event: string, data: Record<string, unknown> = {}) {
if (bytes >= 10_000_000) return
const line = `${JSON.stringify({ time: Date.now(), event, ...data })}\n`
bytes += Buffer.byteLength(line)
void writer.write(line)
},
async close() {
clearInterval(flush)
await writer.end()
},
}
}
+302
View File
@@ -0,0 +1,302 @@
import { REVEAL_WORD_LIMIT, scheduleTextReveal, type TextReveal } from "./animation"
export type Message =
| {
readonly key: string
readonly kind: "user"
readonly itemID: string
readonly text?: string
readonly transcribing: boolean
readonly reveals: ReadonlyArray<TextReveal>
}
| {
readonly key: string
readonly kind: "assistant"
readonly text: string
readonly streaming: boolean
readonly reveals: ReadonlyArray<TextReveal>
}
| {
readonly key: string
readonly kind: "tool"
readonly callID: string
readonly name: string
readonly input: unknown
readonly output?: unknown
}
| { readonly key: string; readonly kind: "meta"; readonly text: string }
type NewMessage = Message extends infer Item ? (Item extends Message ? Omit<Item, "key"> : never) : never
export type VoiceViewState = {
readonly messages: ReadonlyArray<Message>
readonly messageSequence: number
readonly activeUserID?: string
readonly userSequence: number
readonly revealAt: number
readonly revealAnimationEndsAt: number
}
export type VoiceViewEvent =
| { readonly type: "meta"; readonly text: string }
| { readonly type: "user.started" }
| { readonly type: "user.reset" }
| { readonly type: "user.committed"; readonly itemID: string }
| {
readonly type: "user.transcript"
readonly itemID: string
readonly text: string
readonly final: boolean
readonly now: number
readonly animate: boolean
}
| { readonly type: "assistant.delta"; readonly text: string; readonly now: number; readonly animate: boolean }
| { readonly type: "assistant.transcript"; readonly text: string; readonly now: number; readonly animate: boolean }
| { readonly type: "assistant.done" }
| { readonly type: "tool.started"; readonly callID: string; readonly name: string; readonly input: unknown }
| { readonly type: "tool.done"; readonly callID: string; readonly output: unknown }
| { readonly type: "reveals.completed" }
const MESSAGE_LIMIT = 200
export function initialVoiceView(): VoiceViewState {
return { messages: [], messageSequence: 0, userSequence: 0, revealAt: 0, revealAnimationEndsAt: 0 }
}
export function transitionVoiceView(state: VoiceViewState, event: VoiceViewEvent): VoiceViewState {
switch (event.type) {
case "meta":
return append(state, { kind: "meta", text: event.text })
case "user.started": {
if (state.activeUserID) return state
const itemID = `local-user-${state.userSequence + 1}`
return append(
{ ...state, activeUserID: itemID, userSequence: state.userSequence + 1 },
{ kind: "user", itemID, transcribing: true, reveals: [] },
)
}
case "user.reset": {
if (!state.activeUserID) return state
return {
...state,
activeUserID: undefined,
messages: mergeAssistantRows(
state.messages.flatMap((message) => {
if (message.kind !== "user" || message.itemID !== state.activeUserID) return [message]
if (!message.text) return []
return [{ ...message, transcribing: false }]
}),
),
}
}
case "user.committed": {
const index = state.activeUserID
? state.messages.findIndex((message) => message.kind === "user" && message.itemID === state.activeUserID)
: -1
const current = {
...state,
activeUserID: undefined,
messages: state.messages.map((message, currentIndex) => {
if (message.kind === "assistant" && message.streaming) return { ...message, streaming: false }
if (currentIndex === index && message.kind === "user") return { ...message, itemID: event.itemID }
return message
}),
}
if (index !== -1) return current
return append(current, { kind: "user", itemID: event.itemID, transcribing: true, reveals: [] })
}
case "user.transcript": {
const index = state.messages.findIndex((message) => message.kind === "user" && message.itemID === event.itemID)
if (index === -1) {
const revealed = reveal(state, "", event.text, event.now, event.animate)
return append(revealed.state, {
kind: "user",
itemID: event.itemID,
text: event.text,
transcribing: !event.final,
reveals: revealed.reveals,
})
}
const previous = state.messages[index]
if (previous.kind !== "user") return state
const previousText = previous.text ?? ""
if (previousText === event.text && previous.transcribing === !event.final) return state
const appended = event.text.startsWith(previousText)
const revealed = reveal(
state,
appended ? previousText : "",
appended ? event.text.slice(previousText.length) : event.text,
event.now,
event.animate,
)
return {
...revealed.state,
messages: state.messages.map((message, currentIndex) =>
currentIndex === index && message.kind === "user"
? {
...message,
text: event.text,
transcribing: !event.final,
reveals: appended
? [...message.reveals, ...revealed.reveals].slice(-REVEAL_WORD_LIMIT)
: revealed.reveals,
}
: message,
),
}
}
case "assistant.delta": {
const streaming = state.messages.findLastIndex((message) => message.kind === "assistant" && message.streaming)
const lastIndex = state.messages.length - 1
const index = streaming === -1 && state.messages[lastIndex]?.kind === "assistant" ? lastIndex : streaming
const message = state.messages[index]
if (message?.kind !== "assistant") {
const text = event.text.trimStart()
const revealed = reveal(state, "", text, event.now, event.animate)
return append(
{
...revealed.state,
messages: state.messages.map((message) =>
message.kind === "assistant" && message.streaming ? { ...message, streaming: false } : message,
),
},
{ kind: "assistant", text, streaming: true, reveals: revealed.reveals },
)
}
const joined =
streaming === -1
? joinAssistantText(message.text, event.text)
: { text: message.text + event.text, previous: message.text, appended: event.text }
const revealed = reveal(state, joined.previous, joined.appended, event.now, event.animate)
return {
...revealed.state,
messages: state.messages.map((message, currentIndex) =>
currentIndex === index && message.kind === "assistant"
? {
...message,
text: joined.text,
streaming: true,
reveals: [...message.reveals, ...revealed.reveals].slice(-REVEAL_WORD_LIMIT),
}
: message,
),
}
}
case "assistant.transcript": {
const text = event.text.trimStart()
const index = state.messages.findLastIndex((message) => message.kind === "assistant" && message.streaming)
if (index === -1) {
const revealed = reveal(state, "", text, event.now, event.animate)
return append(revealed.state, {
kind: "assistant",
text,
streaming: true,
reveals: revealed.reveals,
})
}
const previous = state.messages[index]
if (previous.kind !== "assistant" || previous.text === text) return state
const appended = text.startsWith(previous.text)
const revealed = reveal(
state,
appended ? previous.text : "",
appended ? text.slice(previous.text.length) : text,
event.now,
event.animate,
)
return {
...revealed.state,
messages: state.messages.map((message, currentIndex) =>
currentIndex === index && message.kind === "assistant"
? {
...message,
text,
reveals: appended
? [...message.reveals, ...revealed.reveals].slice(-REVEAL_WORD_LIMIT)
: revealed.reveals,
}
: message,
),
}
}
case "assistant.done": {
const index = state.messages.findLastIndex((message) => message.kind === "assistant" && message.streaming)
if (index === -1) return state
return {
...state,
messages: state.messages.map((message, currentIndex) =>
currentIndex === index && message.kind === "assistant" ? { ...message, streaming: false } : message,
),
}
}
case "tool.started":
return append(state, {
kind: "tool",
callID: event.callID,
name: event.name,
input: event.input,
})
case "tool.done":
return {
...state,
messages: state.messages.map((message) =>
message.kind === "tool" && message.callID === event.callID ? { ...message, output: event.output } : message,
),
}
case "reveals.completed":
return {
...state,
revealAnimationEndsAt: 0,
messages: state.messages.map((message) =>
"reveals" in message && message.reveals.length > 0 ? { ...message, reveals: [] } : message,
),
}
}
}
function append(state: VoiceViewState, message: NewMessage): VoiceViewState {
const messageSequence = state.messageSequence + 1
const next = { ...message, key: `message-${messageSequence}` }
return { ...state, messageSequence, messages: [...state.messages, next].slice(-MESSAGE_LIMIT) }
}
function reveal(state: VoiceViewState, previous: string, delta: string, now: number, animate: boolean) {
if (!animate) return { state, reveals: new Array<TextReveal>() }
const scheduled = scheduleTextReveal(previous, delta, now, state.revealAt)
return {
state: {
...state,
revealAt: scheduled.nextRevealAt,
revealAnimationEndsAt: Math.max(state.revealAnimationEndsAt, scheduled.animationEndsAt),
},
reveals: scheduled.reveals,
}
}
function mergeAssistantRows(messages: ReadonlyArray<Message>) {
return messages.reduce<Message[]>((result, message) => {
const previous = result.at(-1)
if (previous?.kind !== "assistant" || message.kind !== "assistant") return [...result, message]
return [
...result.slice(0, -1),
{
key: previous.key,
kind: "assistant",
text: joinAssistantText(previous.text, message.text).text,
streaming: previous.streaming || message.streaming,
reveals: [],
},
]
}, [])
}
export function joinAssistantText(left: string, right: string) {
if (left.trim() === "") return { text: right, previous: "", appended: right }
if (right.trim() === "") return { text: left, previous: left, appended: "" }
const head = left.trimEnd()
const tail = right.trimStart()
if ((left.slice(head.length) + right.slice(0, right.length - tail.length)).includes("\n"))
return { text: left + right, previous: left, appended: right }
const separator = /^[.,;:!?…%)\]}]/.test(tail) || /[([{]$/.test(head) ? "" : " "
return { text: head + separator + tail, previous: head + separator, appended: tail }
}
+657
View File
@@ -0,0 +1,657 @@
/** @jsxImportSource @opentui/solid */
// Terminal UI for the voice spike. The TUI keeps conversation order stable
// even though realtime events arrive out of order: a user row is inserted the
// moment the audio buffer commits (before the assistant starts replying) and
// its transcript is filled in when Whisper finishes.
import { createCliRenderer, RGBA } from "@opentui/core"
import { render, useKeyboard } from "@opentui/solid"
import "opentui-spinner/solid"
import { createSignal, For } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { springOpacity, transcriptionPulse, type TextReveal } from "./animation"
import { PCM_METER_FRAME_MS } from "./pcm"
import { initialVoiceView, transitionVoiceView, type Message, type VoiceViewEvent } from "./ui-model"
export { joinAssistantText } from "./ui-model"
export type VoiceStatus = {
server?: string
session?: string
audio?: string
microphoneMuted?: boolean
speakerMuted?: boolean
voice?: string
model?: string
project?: string
}
export type VoiceUI = {
meta(text: string): void
userSpeaking(active: boolean): void
userReset(): void
userAudioLevel(level?: number): void
userCommitted(itemID: string): void
userTranscript(itemID: string, text: string, final?: boolean): void
assistantAudio(level: number, durationMs: number): void
assistantPlaybackStopped(): void
assistantDelta(text: string): void
assistantTranscript(text: string): void
assistantDone(): void
toolStart(callID: string, name: string, input: unknown): void
toolDone(callID: string, output: unknown): void
setStatus(patch: VoiceStatus): void
close(): void
}
const truncate = (text: string, max: number) => (text.length > max ? text.slice(0, max) + "…" : text)
const displayJson = (value: unknown) =>
truncate(
(() => {
try {
return (
JSON.stringify(
value,
(_, item) =>
typeof item === "string" ? truncate(item, 500) : typeof item === "bigint" ? String(item) : item,
2,
) ?? "null"
)
} catch {
return "[unserializable value]"
}
})(),
8_000,
)
function toolSummary(name: string, output?: unknown) {
if (output === undefined) return "running"
if (Array.isArray(output)) return `${output.length} result${output.length === 1 ? "" : "s"}`
if (typeof output === "string") return truncate(output, 160)
if (typeof output === "number" || typeof output === "boolean" || typeof output === "bigint") return String(output)
if (output === null || typeof output !== "object") return "completed"
if ("status" in output && typeof output.status === "string")
return output.status === "started" && "notification" in output && output.notification === "registered"
? "started · notification registered"
: output.status
const fields = Object.entries(output)
.filter(
([key, item]) =>
!key.endsWith("_id") && key !== "notification" && ["string", "number", "boolean"].includes(typeof item),
)
.slice(0, 2)
.map(([key, item]) => `${key}: ${String(item)}`)
return fields.join(" · ") || "completed"
}
// ---------------------------------------------------------------------------
// Console fallback (--text mode, non-TTY)
// ---------------------------------------------------------------------------
export function createConsoleUI(): VoiceUI {
const tty = process.stdout.isTTY
const dim = (text: string) => (tty ? `\x1b[2m${text}\x1b[0m` : text)
const cyan = (text: string) => (tty ? `\x1b[1;36m${text}\x1b[0m` : text)
const green = (text: string) => (tty ? `\x1b[1;32m${text}\x1b[0m` : text)
let streaming = false
const tools = new Map<string, string>()
const line = (text: string) => {
if (streaming) {
process.stdout.write("\n")
streaming = false
}
console.log(text)
}
return {
meta: (text) => line(dim(` ${text}`)),
userSpeaking: () => {},
userReset: () => {},
userAudioLevel: () => {},
userCommitted: () => {},
userTranscript: (_, text) => line(cyan("● you ") + text),
assistantAudio: () => {},
assistantPlaybackStopped: () => {},
assistantDelta: (text) => {
if (!streaming) {
process.stdout.write(green("● assistant "))
streaming = true
}
process.stdout.write(text)
},
assistantTranscript: (text) => line(green("● assistant ") + text),
assistantDone: () => {
if (streaming) process.stdout.write("\n")
streaming = false
},
toolStart: (callID, name) => {
tools.set(callID, name)
line(dim(`${name} running`))
},
toolDone: (callID, output) => {
const name = tools.get(callID) ?? "tool"
tools.delete(callID)
line(dim(`${name} ${toolSummary(name, output)}`))
},
setStatus: () => {},
close: () => {},
}
}
// ---------------------------------------------------------------------------
// OpenTUI
// ---------------------------------------------------------------------------
const theme = {
text: "#c0caf5",
muted: "#565f89",
you: "#7dcfff",
assistant: "#9ece6a",
key: "#7aa2f7",
string: "#9ece6a",
number: "#e0af68",
literal: "#bb9af7",
}
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
const colors = {
text: RGBA.fromHex(theme.text),
you: RGBA.fromHex(theme.you),
assistant: RGBA.fromHex(theme.assistant),
}
const AUDIO_LEVEL_STALE_MS = 250
const TOOL_DISPLAY_DELAY_MS = 160
const meterGlyphs = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
function fade(color: RGBA, opacity: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * opacity)
}
function meter(level: number, phase: number) {
return [0, 1, 2, 3]
.map((index) => {
const value = Math.max(0, Math.min(1, level * (0.72 + Math.sin(phase + index * 1.4) * 0.28)))
return meterGlyphs[Math.round(value * (meterGlyphs.length - 1))]
})
.join("")
}
// Tiny JSON tokenizer for syntax-highlighted tool results.
function jsonTokens(json: string) {
const tokens: Array<{ text: string; color: string }> = []
const pattern = /("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)|(true|false|null)|([{}[\],:]+|\s+)/g
for (const match of json.matchAll(pattern)) {
if (match[1] !== undefined) {
tokens.push({ text: match[1], color: match[2] ? theme.key : theme.string })
if (match[2]) tokens.push({ text: match[2], color: theme.muted })
continue
}
if (match[3] !== undefined) {
tokens.push({ text: match[3], color: theme.number })
continue
}
if (match[4] !== undefined) {
tokens.push({ text: match[4], color: theme.literal })
continue
}
tokens.push({ text: match[5] ?? "", color: theme.muted })
}
return tokens
}
function RevealedText(props: {
text: string
reveals: ReadonlyArray<TextReveal>
now: () => number
opacity: () => number
}) {
return (
<>
<span style={{ fg: fade(colors.text, props.opacity()) }}>
{props.text.slice(0, props.reveals[0]?.offset ?? props.text.length)}
</span>
<For each={props.reveals}>
{(reveal, index) => (
<span style={{ fg: fade(colors.text, props.opacity() * springOpacity(props.now(), reveal.at)) }}>
{props.text.slice(reveal.offset, props.reveals[index() + 1]?.offset ?? props.text.length)}
</span>
)}
</For>
</>
)
}
// Message transitions preserve unchanged object identities. A changed message
// gets a new object, so keyed <For> replaces that row and this branch reruns.
function MessageRow(props: {
message: Message
details: boolean
assistantLevel: () => number
userLevel: () => number
now: () => number
animate: boolean
microphoneMuted: boolean
}) {
const message = props.message
if (message.kind === "user") {
const transcribing = () => message.transcribing && !props.microphoneMuted
const pulse = () =>
!props.animate || !transcribing() ? 0 : Math.max(props.userLevel(), transcriptionPulse(props.now()) * 0.12)
return (
<box
width="100%"
flexDirection="column"
flexShrink={0}
border={["left"]}
borderColor={fade(colors.you, 0.38 + pulse() * 0.32)}
backgroundColor={fade(colors.you, 0.035 + pulse() * 0.07)}
paddingLeft={1}
paddingRight={1}
marginTop={1}
>
<box width="100%" minWidth={0} flexDirection="row">
{transcribing() ? (
<text width={4} flexShrink={0} fg={fade(colors.you, 0.72 + pulse() * 0.2)}>
<b>{props.animate ? meter(Math.max(0.12, pulse()), props.now() / 240) : "..."}</b>
</text>
) : null}
<text flexGrow={1} minWidth={0} wrapMode="word">
<RevealedText
text={message.text ?? "transcribing"}
reveals={message.reveals}
now={props.now}
opacity={() => 0.74 + pulse() * 0.2}
/>
</text>
</box>
</box>
)
}
if (message.kind === "assistant") {
const pulse = () => (!props.animate || !message.streaming ? 0.5 : props.assistantLevel())
return (
<box
width="100%"
flexDirection="column"
flexShrink={0}
border={["left"]}
borderColor={fade(colors.assistant, message.streaming ? 0.58 + pulse() * 0.42 : 0.3)}
backgroundColor={fade(colors.assistant, message.streaming ? 0.055 + pulse() * 0.11 : 0.025)}
paddingLeft={1}
paddingRight={1}
marginTop={1}
>
<text width="100%" minWidth={0} wrapMode="word">
<RevealedText
text={message.text}
reveals={message.reveals}
now={props.now}
opacity={() => (message.streaming ? 1 : 0.7)}
/>
</text>
</box>
)
}
if (message.kind === "tool")
return (
<box width="100%" flexDirection="column" paddingLeft={2} marginTop={1}>
<box width="100%" minWidth={0} flexDirection="row">
<box width={3} flexShrink={0}>
{message.output === undefined ? (
props.animate ? (
<spinner frames={SPINNER_FRAMES} interval={80} color={theme.number} />
) : (
<text fg={theme.number}></text>
)
) : (
<text fg={theme.assistant}></text>
)}
</box>
<text flexGrow={1} minWidth={0} wrapMode="word" fg={theme.muted}>
<span style={{ fg: theme.key }}>{message.name}</span>
{" "}
{toolSummary(message.name, message.output)}
</text>
</box>
{props.details ? (
<text width="100%" minWidth={0} wrapMode="word" fg={theme.muted} paddingLeft={2}>
<For each={jsonTokens(displayJson({ input: message.input, output: message.output }))}>
{(token) => <span style={{ fg: token.color }}>{token.text}</span>}
</For>
</text>
) : null}
</box>
)
return (
<box width="100%" minWidth={0} flexDirection="row" paddingLeft={2}>
<text width={3} flexShrink={0} fg={theme.muted}>
·
</text>
<text flexGrow={1} minWidth={0} wrapMode="word" fg={theme.muted}>
{message.text}
</text>
</box>
)
}
export async function createVoiceTUI(options: {
onInterrupt(): void
onExit(): void
onCycleVoice(): void
onToggleMicrophone(): void
onToggleSpeaker(): void
reducedMotion?: boolean
}): Promise<VoiceUI> {
let view = initialVoiceView()
const [messageState, setMessageState] = createStore({ items: [...view.messages] })
const [status, setStatus] = createSignal<VoiceStatus>({})
const [details, setDetails] = createSignal(false)
const [animationFrame, setAnimationFrame] = createSignal(performance.now())
let userActive = false
let animationTimer: ReturnType<typeof setInterval> | undefined
let assistantSegments: Array<{ start: number; end: number; level: number }> = []
let assistantSegmentIndex = 0
let assistantScheduledUntil = 0
let assistantLevel = 0
let userTargetLevel = 0
let userLevel = 0
let userLevelAt = 0
const pendingTools = new Map<
string,
{
name: string
input: unknown
output?: unknown
completed: boolean
showAt: number
timer: ReturnType<typeof setTimeout>
}
>()
const pendingMetaTimers = new Set<ReturnType<typeof setTimeout>>()
const renderer = await createCliRenderer({
useMouse: true,
// Handle Ctrl-C in OpenTUI's native input parser, before Solid handlers
// and rendering work. onDestroy performs process/audio cleanup below.
exitOnCtrlC: true,
exitSignals: [],
autoFocus: false,
openConsoleOnError: false,
screenMode: "alternate-screen",
externalOutputMode: "passthrough",
consoleMode: "disabled",
onDestroy: () => {
if (animationTimer) clearInterval(animationTimer)
pendingTools.forEach((tool) => clearTimeout(tool.timer))
pendingMetaTimers.forEach(clearTimeout)
options.onExit()
},
})
const applyView = (event: VoiceViewEvent) => {
const next = transitionVoiceView(view, event)
if (next === view) return false
view = next
setMessageState("items", reconcile([...view.messages], { key: "key" }))
return true
}
const startAnimation = () => {
if (options.reducedMotion) return
if (animationTimer) return
animationTimer = setInterval(() => {
const now = performance.now()
while (assistantSegments[assistantSegmentIndex]?.end <= now) assistantSegmentIndex += 1
const segment = assistantSegments[assistantSegmentIndex]
const output = segment?.start <= now ? segment : undefined
const assistantTarget = output?.level ?? 0
const userTarget = now - userLevelAt < AUDIO_LEVEL_STALE_MS ? userTargetLevel : 0
assistantLevel += (assistantTarget - assistantLevel) * (assistantTarget > assistantLevel ? 0.5 : 0.16)
userLevel += (userTarget - userLevel) * (userTarget > userLevel ? 0.5 : 0.18)
setAnimationFrame(now)
if (view.revealAnimationEndsAt > 0 && now >= view.revealAnimationEndsAt) applyView({ type: "reveals.completed" })
const transcribing =
!status().microphoneMuted &&
messageState.items.some((message) => message.kind === "user" && message.transcribing)
if (
userActive ||
transcribing ||
now < view.revealAnimationEndsAt ||
assistantSegmentIndex < assistantSegments.length ||
assistantLevel > 0.01 ||
userLevel > 0.01
)
return
clearInterval(animationTimer)
animationTimer = undefined
}, PCM_METER_FRAME_MS)
}
const currentAssistantLevel = () => {
animationFrame()
return assistantLevel
}
const currentUserLevel = () => {
animationFrame()
return userLevel
}
function App() {
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") return
if (evt.repeated || evt.ctrl || evt.meta || evt.option || evt.shift) return
if (evt.name === "v") {
evt.preventDefault()
return options.onCycleVoice()
}
if (evt.name === "m") {
evt.preventDefault()
return options.onToggleMicrophone()
}
if (evt.name === "s") {
evt.preventDefault()
return options.onToggleSpeaker()
}
if (evt.name === "d") {
evt.preventDefault()
setDetails((current) => !current)
return
}
if (evt.name === "escape") {
evt.preventDefault()
options.onInterrupt()
}
})
const runtimeLine = () =>
[
status().audio ?? "connecting…",
status().microphoneMuted ? "mic muted" : undefined,
status().speakerMuted ? "speaker muted" : undefined,
status().voice,
status().model,
status().project,
status().session ?? "no session",
]
.filter(Boolean)
.join(" ")
return (
<box flexDirection="column" width="100%" height="100%" paddingLeft={1} paddingRight={1}>
<box width="100%" flexGrow={1} minHeight={0} minWidth={0}>
<scrollbox width="100%" height="100%" stickyScroll stickyStart="bottom" scrollbarOptions={{ visible: false }}>
<box width="100%" minWidth={0} flexDirection="column" flexShrink={0}>
<For each={messageState.items}>
{(message) => (
<MessageRow
message={message}
details={details()}
assistantLevel={currentAssistantLevel}
userLevel={currentUserLevel}
now={animationFrame}
animate={!options.reducedMotion}
microphoneMuted={status().microphoneMuted === true}
/>
)}
</For>
</box>
</scrollbox>
</box>
<box width="100%" height={2} flexShrink={0} backgroundColor="#0d1018" paddingLeft={1} paddingRight={1}>
<text width="100%" wrapMode="none" truncate fg={fade(colors.text, 0.78)}>
{runtimeLine()}
</text>
<text width="100%" wrapMode="none" truncate fg={theme.muted}>
<span style={{ fg: theme.key }}>esc</span> interrupt {" "}
<span style={{ fg: theme.key }}>v</span> voice {" "}
<span style={{ fg: theme.key }}>m</span> mic {" "}
<span style={{ fg: theme.key }}>s</span> speaker {" "}
<span style={{ fg: theme.key }}>d</span> details {details() ? "on" : "off"} {" "}
<span style={{ fg: theme.key }}>ctrl+c</span> quit
</text>
</box>
</box>
)
}
await render(() => <App />, renderer)
const pushMeta = (text: string) => {
if (pendingTools.size === 0) return void applyView({ type: "meta", text })
const showAt = Math.max(...[...pendingTools.values()].map((tool) => tool.showAt))
const timer = setTimeout(
() => {
pendingMetaTimers.delete(timer)
applyView({ type: "meta", text })
},
Math.max(0, showAt - performance.now()) + 1,
)
pendingMetaTimers.add(timer)
}
return {
meta: pushMeta,
userSpeaking: (active) => {
if (active) applyView({ type: "user.started" })
userActive = active
if (active) startAnimation()
if (!active) userTargetLevel = 0
},
userReset: () => {
userActive = false
userTargetLevel = 0
userLevel = 0
applyView({ type: "user.reset" })
},
userAudioLevel: (level) => {
if (level === undefined) {
userLevelAt = 0
return
}
userTargetLevel = Math.max(0, Math.min(1, level))
userLevelAt = performance.now()
if (userActive) startAnimation()
},
userCommitted: (itemID) => {
userActive = false
applyView({ type: "user.committed", itemID })
startAnimation()
},
userTranscript: (itemID, text, final = true) => {
applyView({
type: "user.transcript",
itemID,
text,
final,
now: performance.now(),
animate: !options.reducedMotion,
})
if (!final || view.revealAnimationEndsAt > 0) startAnimation()
},
assistantAudio: (level, durationMs) => {
if (options.reducedMotion) return
if (assistantSegmentIndex > 512) {
assistantSegments = assistantSegments.slice(assistantSegmentIndex)
assistantSegmentIndex = 0
}
const now = performance.now()
const start = Math.max(now, assistantScheduledUntil)
const end = start + durationMs
assistantSegments.push({ start, end, level: Math.max(0, Math.min(1, level)) })
assistantScheduledUntil = end
startAnimation()
},
assistantPlaybackStopped: () => {
assistantSegments = []
assistantSegmentIndex = 0
assistantScheduledUntil = 0
assistantLevel = 0
setAnimationFrame(performance.now())
},
assistantDelta: (text) => {
applyView({
type: "assistant.delta",
text,
now: performance.now(),
animate: !options.reducedMotion,
})
if (view.revealAnimationEndsAt > 0) startAnimation()
},
assistantTranscript: (text) => {
applyView({
type: "assistant.transcript",
text,
now: performance.now(),
animate: !options.reducedMotion,
})
if (view.revealAnimationEndsAt > 0) startAnimation()
},
assistantDone: () => {
assistantSegments = []
assistantSegmentIndex = 0
assistantScheduledUntil = 0
assistantLevel = 0
applyView({ type: "assistant.done" })
},
toolStart: (callID, name, input) => {
if (
pendingTools.has(callID) ||
view.messages.some((message) => message.kind === "tool" && message.callID === callID)
)
return
const pending = {
name,
input,
completed: false,
showAt: performance.now() + TOOL_DISPLAY_DELAY_MS,
timer: setTimeout(() => {
const current = pendingTools.get(callID)
if (!current) return
pendingTools.delete(callID)
applyView({
type: "tool.started",
callID,
name: current.name,
input: current.input,
})
if (current.completed) applyView({ type: "tool.done", callID, output: current.output })
}, TOOL_DISPLAY_DELAY_MS),
}
pendingTools.set(callID, pending)
},
toolDone: (callID, output) => {
const pending = pendingTools.get(callID)
if (pending) {
pending.output = output
pending.completed = true
return
}
applyView({ type: "tool.done", callID, output })
},
setStatus: (patch) => {
setStatus((current) => ({ ...current, ...patch }))
},
close: () => {
if (!renderer.isDestroyed) renderer.destroy()
},
}
}

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