Compare commits

..

14 Commits

Author SHA1 Message Date
Shoubhit Dash 9bf98793d9 refactor(cli): simplify auth command flow 2026-08-18 02:49:07 +05:30
Shoubhit Dash a5cb47f4ce fix(cli): restore auth help descriptions 2026-08-18 02:38:37 +05:30
Shoubhit Dash ef8685ab77 feat(cli): add interactive auth commands 2026-08-18 02:24:52 +05:30
opencode-agent[bot] f161c90057 chore: generate 2026-08-17 20:21:54 +00:00
opencode-agent[bot] 728ae7c949 fix(stats): prefer Go catalog pricing (#43120) 2026-08-17 15:20:06 -05:00
Dax 42fc297d30 feat(tui): inherit terminal environment per session (#42957) 2026-08-17 20:18:28 +00:00
Aiden Cline 43dd33842e fix(ai): default Gemini function args (#43111) 2026-08-17 13:43:52 -05:00
opencode-agent[bot] 3d13b6c5b6 chore: generate 2026-08-17 18:14:05 +00:00
Aiden Cline 996b05432f fix(ai): preserve Gemini prompt safety blocks (#43070) 2026-08-17 13:12:51 -05:00
Aiden Cline c53f4cfb09 fix(core): clarify temporary directory guidance (#43095) 2026-08-17 11:50:51 -05:00
Kit Langton 45a49ae32a fix(core): preserve previous V2 database lineage (#43092) 2026-08-17 12:39:02 -04:00
Kit Langton 238ce304a9 fix(tui): expose queued prompt shortcut (#43083) 2026-08-17 12:33:18 -04:00
opencode-agent[bot] cf606660fb fix(core): settle externally signaled shells (#43086)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-08-17 21:57:35 +05:30
Kit Langton 759695d87c fix(tui): show full tab numbers (#43081) 2026-08-17 11:30:49 -04:00
79 changed files with 1818 additions and 945 deletions
+1 -1
View File
@@ -121,6 +121,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -615,7 +616,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:",
},
},
+41 -24
View File
@@ -92,7 +92,7 @@ const GeminiFunctionCallPart = Schema.Struct({
functionCall: Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.String,
args: Schema.Unknown,
args: Schema.optional(Schema.Unknown),
}),
thoughtSignature: Schema.optional(Schema.String),
})
@@ -190,8 +190,19 @@ const GeminiCandidate = Schema.Struct({
finishReason: Schema.optional(Schema.String),
})
const GeminiPromptFeedback = Schema.StructWithRest(
Schema.Struct({
blockReason: Schema.optional(Schema.String),
blockReasonMessage: Schema.optional(Schema.String),
safetyRatings: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
const GeminiEvent = Schema.Struct({
candidates: optionalArray(GeminiCandidate),
promptFeedback: Schema.optional(GeminiPromptFeedback),
usageMetadata: Schema.optional(GeminiUsage),
})
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
@@ -200,6 +211,7 @@ interface ParserState {
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly nextToolCallId: number
readonly promptFeedback?: GeminiPromptFeedback
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
@@ -503,32 +515,37 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
return "unknown"
}
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.finishReason || state.usage
? (() => {
const events: LLMEvent[] = []
const lifecycle = state.reasoningSignature
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
: state.lifecycle
Lifecycle.finish(lifecycle, events, {
reason: {
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
raw: state.finishReason,
},
usage: state.usage,
})
return events
})()
: []
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
const finishReason = state.finishReason ?? promptBlockReason
if (finishReason === undefined && state.usage === undefined) return []
const events: LLMEvent[] = []
const lifecycle = state.reasoningSignature
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
: state.lifecycle
Lifecycle.finish(lifecycle, events, {
reason: {
normalized:
promptBlockReason === undefined ? mapFinishReason(finishReason, state.hasToolCalls) : "content-filter",
raw: finishReason,
},
usage: state.usage,
providerMetadata:
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
})
return events
}
const step = (state: ParserState, event: GeminiEvent) => {
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
}
const candidate = event.candidates?.[0]
@@ -569,7 +586,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
}
if ("functionCall" in part) {
const input = part.functionCall.args
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
const id = `tool_${nextToolCallId++}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
+70
View File
@@ -767,6 +767,31 @@ describe("Gemini route", () => {
}),
)
it.effect("defaults omitted function call args to an empty object", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "ping", description: "Ping", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: { role: "model", parts: [{ functionCall: { name: "ping" } }] },
finishReason: "STOP",
},
],
}),
),
),
)
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
}),
)
it.effect("maps tool calls without a finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -862,6 +887,51 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves candidate-less prompt safety blocks as content-filter outcomes", () =>
Effect.gen(function* () {
const blocked = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
promptFeedback: {
blockReason: "FUTURE_SAFETY_REASON",
blockReasonMessage: "Prompt blocked",
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
},
}),
),
),
)
const blockedWithUsage = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ promptFeedback: { blockReason: "SAFETY" } },
{ usageMetadata: { promptTokenCount: 7, totalTokenCount: 7 } },
),
),
),
)
expect(blocked.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(blocked.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "content-filter", raw: "FUTURE_SAFETY_REASON" },
providerMetadata: {
google: {
promptFeedback: {
blockReason: "FUTURE_SAFETY_REASON",
blockReasonMessage: "Prompt blocked",
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
},
},
},
})
expect(blockedWithUsage.finishReason).toEqual({ normalized: "content-filter", raw: "SAFETY" })
expect(blockedWithUsage.usage).toMatchObject({ inputTokens: 7, totalTokens: 7 })
}),
)
it.effect("maps current blocking and invalid-output finish reasons", () =>
Effect.gen(function* () {
const reasons = [
+1
View File
@@ -23,6 +23,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
+29 -4
View File
@@ -86,12 +86,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
}),
Spec.make("auth", {
description: "Manage authentication",
description: "manage AI providers and credentials",
commands: [
Spec.make("login", {
description: "Log in to a well-known authentication provider",
Spec.make("list", {
description: "list providers and credentials",
params: {
url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")),
...ServerParams,
format: Flag.choice("format", ["default", "json"]).pipe(
Flag.withDescription("Output format"),
Flag.withDefault("default"),
),
},
}),
Spec.make("login", {
description: "log in to a provider",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID, name, or well-known provider URL"),
Argument.optional,
),
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
},
}),
Spec.make("logout", {
description: "log out from a configured provider",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID or name"),
Argument.optional,
),
},
}),
],
@@ -0,0 +1,155 @@
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
import { Effect } from "effect"
import type { FormAnswer, FormField, FormFields } from "@opencode-ai/client"
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
const skip = Symbol("skip")
const custom = Symbol("custom")
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
if (!fields) return undefined
yield* requireInteractive("Authentication form input requires an interactive terminal")
const answer: FormAnswer = {}
for (const field of fields) {
if (!active(field, answer)) continue
const value = yield* answerField(field)
if (value !== undefined) answer[field.key] = value
}
return answer
})
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
yield* requireInteractive("API key input requires an interactive terminal")
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
})
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
const message = field.title ?? field.key
if (field.description) log.info(field.description)
if (field.type === "external") {
log.info(field.url)
yield* openUrl(field.url)
const acknowledged = yield* prompt<boolean>(() =>
confirm({ message: message || "Continue after completing this step?", initialValue: true }),
)
if (!acknowledged) return yield* Effect.fail(new Error(`${message || "External step"} is required`))
return true
}
if (field.type === "boolean") {
if (field.required) return yield* prompt<boolean>(() => confirm({ message, initialValue: field.default ?? true }))
const options: Array<Option<boolean | typeof skip>> = [
{ value: true, label: "Yes" },
{ value: false, label: "No" },
{ value: skip, label: "Skip" },
]
const value = yield* prompt<boolean | typeof skip>(() =>
select<boolean | typeof skip>({
message,
options,
initialValue: field.default ?? skip,
}),
)
if (value === skip) return undefined
return value
}
if (field.type === "multiselect") {
const options: Array<Option<string | typeof custom>> = field.options.map((option) => ({
value: option.value,
label: option.label,
hint: option.description,
}))
if (field.custom) options.push({ value: custom, label: "Type another value" })
const values = yield* prompt<Array<string | typeof custom>>(() =>
multiselect<string | typeof custom>({
message,
options,
initialValues: field.default,
required: field.required || (field.minItems ?? 0) > 0,
}),
)
const selected = values.filter((value): value is string => value !== custom)
if (values.includes(custom)) {
selected.push(yield* prompt<string>(() => text({ message: "Enter value", validate: required })))
}
const invalid = validateMultiselect(field, selected)
if (invalid) return yield* Effect.fail(new Error(invalid))
return selected
}
if (field.type === "string" && field.options) {
const options: Array<Option<string | typeof custom | typeof skip>> = field.options.map((option) => ({
value: option.value,
label: option.label,
hint: option.description,
}))
if (field.custom) options.push({ value: custom, label: "Type your own answer" })
if (!field.required) options.push({ value: skip, label: "Skip" })
const value = yield* prompt<string | typeof custom | typeof skip>(() =>
select<string | typeof custom | typeof skip>({ message, options, initialValue: field.default }),
)
if (value === skip) return undefined
if (value !== custom) return value
}
const value = yield* prompt<string>(() =>
text({
message,
placeholder: field.type === "string" ? field.placeholder : undefined,
initialValue: field.default === undefined ? undefined : String(field.default),
validate: (input) => validateText(field, input),
}),
)
if (!value && !field.required) return undefined
if (field.type === "string") return value
return Number(value)
})
function active(field: FormField, answer: FormAnswer) {
if (field.type === "external" || !field.when) return true
return field.when.every((condition) => {
const value = answer[condition.key]
if (value === undefined) return false
const matches = Array.isArray(value) ? value.includes(String(condition.value)) : value === condition.value
return condition.op === "eq" ? matches : !matches
})
}
function required(value: string | undefined) {
return value ? undefined : "Required"
}
function validateText(field: Exclude<FormField, { type: "boolean" | "external" | "multiselect" }>, value?: string) {
if (!value) return field.required ? "Required" : undefined
if (field.type === "number" || field.type === "integer") {
const number = Number(value)
if (!Number.isFinite(number)) return "Expected a number"
if (field.type === "integer" && !Number.isInteger(number)) return "Expected an integer"
if (typeof field.minimum === "number" && number < field.minimum) return `Must be at least ${field.minimum}`
if (typeof field.maximum === "number" && number > field.maximum) return `Must be at most ${field.maximum}`
return undefined
}
if (field.minLength !== undefined && value.length < field.minLength)
return `Must be at least ${field.minLength} characters`
if (field.maxLength !== undefined && value.length > field.maxLength)
return `Must be at most ${field.maxLength} characters`
if (field.pattern) {
try {
if (!new RegExp(field.pattern).test(value)) return "Invalid format"
} catch {
return "Invalid format"
}
}
if (field.format === "uri" && !URL.canParse(value)) return "Expected a URL"
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
if (field.format === "date") {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "Expected a date"
const date = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return "Expected a date"
}
if (field.format === "date-time" && Number.isNaN(Date.parse(value))) return "Expected a date and time"
return undefined
}
function validateMultiselect(field: Extract<FormField, { type: "multiselect" }>, value: string[]) {
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
return undefined
}
@@ -0,0 +1,42 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, loadIntegrations } from "./shared"
import { handleCommandErrors } from "../../../ui/prompt"
export default Runtime.handler(Commands.commands.auth.commands.list, (input) => list(input).pipe(handleCommandErrors))
const list = Effect.fn("cli.auth.list")(function* (input) {
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0)
if (input.format === "json") {
process.stdout.write(
JSON.stringify(
integrations.map((integration) => ({
id: integration.id,
name: integration.name,
connections: integration.connections,
})),
null,
2,
) + EOL,
)
return
}
const rows = integrations.flatMap((integration) =>
integration.connections.map((connection) => ({
integration: integration.name,
source: connection.type === "credential" ? connection.label : connection.name,
type: connection.type === "credential" ? "stored" : "environment",
})),
)
if (rows.length === 0) {
process.stdout.write("No authenticated integrations" + EOL)
return
}
const width = Math.max(...rows.map((row) => row.integration.length)) + 2
process.stdout.write(
rows.map((row) => row.integration.padEnd(width) + row.source.padEnd(28) + row.type).join(EOL) + EOL,
)
})
+254 -37
View File
@@ -1,52 +1,269 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
import { answerForm, secret } from "./form"
import {
createClient,
connectMethods,
loadIntegrations,
location,
methodID,
methodLabel,
request,
resolveIntegration,
resolveMethod,
type ConnectMethod,
} from "./shared"
const location = { directory: process.cwd() }
const integrationPriority = new Map([
["opencode", 0],
["opencode-go", 1],
["openai", 2],
["github-copilot", 3],
["google", 4],
["anthropic", 5],
["openrouter", 6],
["vercel", 7],
])
export default Runtime.handler(
Commands.commands.auth.commands.login,
Effect.fn("cli.auth.login")(function* (input) {
process.stdout.write("Logging in..." + EOL + EOL)
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
const integrationID = input.url.replace(/\/+$/, "")
const started = yield* request(() =>
client.integration.command.connect({ integrationID, methodID: "login", location }),
)
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }),
).pipe(Effect.ignore),
)
const status = yield* wait(client, integrationID, started.data.attemptID)
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
process.stdout.write("Logged in" + EOL)
}),
Effect.fn("cli.auth.login")((input) =>
login({
target: Option.getOrUndefined(input.target),
method: Option.getOrUndefined(input.method),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
),
)
const wait = (
const login = Effect.fn("cli.auth.login.run")(function* (input: {
target?: string
method?: string
server?: string
standalone: boolean
}) {
if (!input.target)
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
intro("Connect an integration")
const client = yield* createClient({ server: input.server, standalone: input.standalone })
const integration = yield* findIntegration(client, input.target)
const methods = connectMethods(integration)
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
const method = yield* chooseMethod(methods, input.method)
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
yield* authenticate(client, integration, method, answer)
outro("Done")
})
const findIntegration = Effect.fn("cli.auth.login.integration")(function* (client: OpenCodeClient, target?: string) {
if (target && isURL(target)) {
const progress = spinner()
progress.start("Discovering authentication provider...")
yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))),
)
}
const integrations = yield* loadIntegrations(client)
if (target) return yield* resolveIntegration(integrations, target)
const available = integrations
.filter((integration) => connectMethods(integration).length > 0)
.toSorted(
(a, b) =>
(integrationPriority.get(a.id) ?? integrationPriority.size) -
(integrationPriority.get(b.id) ?? integrationPriority.size) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id),
)
if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
const id = yield* prompt<string>(() =>
autocomplete({
message: "Select integration",
maxItems: 8,
options: available.map((integration) => {
const option = { value: integration.id, label: integration.name, hint: integration.id }
if (integration.connections.length > 0) return { ...option, hint: "connected" }
if (integration.id === "opencode") return { ...option, hint: "recommended" }
return option
}),
}),
)
return yield* resolveIntegration(available, id)
})
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
if (target) return yield* resolveMethod(methods, target)
if (methods.length === 1) return methods[0]
yield* requireInteractive("Pass --method when running without an interactive terminal")
const id = yield* prompt<string>(() =>
select({
message: "Select login method",
options: methods.map((method) => ({ value: methodID(method), label: methodLabel(method) })),
}),
)
return yield* resolveMethod(methods, id)
})
const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: ConnectMethod,
answer?: FormAnswer,
) {
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
if (method.type === "command") return yield* commandLogin(client, integration, method)
return yield* oauthLogin(client, integration, method, answer)
})
const keyLogin = Effect.fn("cli.auth.login.key")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
answer?: FormAnswer,
) {
const key = yield* secret(method.label ?? `Enter your ${integration.name} API key`)
const progress = spinner()
progress.start("Saving credential...")
yield* request((signal) =>
client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal }),
).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Connected to ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))),
)
})
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "oauth" }>,
answer?: FormAnswer,
) {
const progress = spinner()
progress.start("Starting authorization...")
const started = yield* request((signal) =>
client.integration.oauth.connect(
{ integrationID: integration.id, methodID: method.id, answer, location },
{ signal },
),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
const attempt = started.data
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.oauth.cancel(
{ integrationID: integration.id, attemptID: attempt.attemptID, location },
{ signal: AbortSignal.timeout(5_000) },
),
).pipe(Effect.ignore),
)
progress.stop("Authorization started")
log.info(attempt.instructions)
log.info(attempt.url)
if (process.stdin.isTTY && process.stdout.isTTY) yield* openUrl(attempt.url)
if (attempt.mode === "code") {
yield* requireInteractive("This login requires an interactive terminal to enter the authorization code")
const code = yield* prompt<string>(() =>
text({ message: "Paste the authorization code", validate: (value) => (!value ? "Required" : undefined) }),
)
const completing = spinner()
completing.start("Completing authorization...")
yield* request((signal) =>
client.integration.oauth.complete(
{ integrationID: integration.id, attemptID: attempt.attemptID, code, location },
{ signal },
),
).pipe(
Effect.tap(() => Effect.sync(() => completing.stop(`Connected to ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => completing.stop("Authentication failed", 1))),
)
return
}
const waiting = spinner()
waiting.start("Waiting for authorization...")
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
)
if (status.status === "complete") {
waiting.stop(`Connected to ${integration.name}`)
return
}
waiting.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authorization expired"))
})
const commandLogin = Effect.fn("cli.auth.login.command")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "command" }>,
) {
const progress = spinner()
progress.start("Starting authentication command...")
const started = yield* request((signal) =>
client.integration.command.connect({ integrationID: integration.id, methodID: method.id, location }, { signal }),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.command.cancel(
{
integrationID: integration.id,
attemptID: started.data.attemptID,
location,
},
{ signal: AbortSignal.timeout(5_000) },
),
).pipe(Effect.ignore),
)
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
progress.message(message.trim() || "Waiting for authentication command..."),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
if (status.status === "complete") {
progress.stop(`Connected to ${integration.name}`)
return
}
progress.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authentication expired"))
})
const waitForOAuth = Effect.fn("cli.auth.login.oauth.wait")(function* (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
shown = false,
): Effect.Effect<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, unknown> =>
Effect.gen(function* () {
const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location }))
) {
while (true) {
const response = yield* request((signal) =>
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
)
if (response.data.status !== "pending") return response.data
const output = response.data.message?.trim()
if (!shown && output) process.stdout.write(output + EOL + EOL)
yield* Effect.sleep(500)
return yield* wait(client, integrationID, attemptID, shown || !!output)
})
}
})
function request<A>(task: () => Promise<A>) {
return Effect.tryPromise({ try: task, catch: (cause) => cause })
const waitForCommand = Effect.fn("cli.auth.login.command.wait")(function* (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
update: (message: string) => void,
) {
while (true) {
const response = yield* request((signal) =>
client.integration.command.status({ integrationID, attemptID, location }, { signal }),
)
if (response.data.status !== "pending") return response.data
if (response.data.message) update(response.data.message)
yield* Effect.sleep(500)
}
})
function isURL(value: string) {
if (!URL.canParse(value)) return false
const protocol = new URL(value).protocol
return protocol === "http:" || protocol === "https:"
}
@@ -0,0 +1,83 @@
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { IntegrationInfo } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
export default Runtime.handler(
Commands.commands.auth.commands.logout,
Effect.fn("cli.auth.logout")((input) =>
logout({
target: Option.getOrUndefined(input.target),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
),
)
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
target?: string
server?: string
standalone: boolean
}) {
if (!input.target)
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
intro("Remove credential")
const client = yield* createClient({ server: input.server, standalone: input.standalone })
const integrations = yield* loadIntegrations(client)
const integration = yield* chooseIntegration(integrations, input.target)
const credentials = integration.connections.filter((connection) => connection.type === "credential")
if (credentials.length === 0) {
const environment = integration.connections
.filter((connection) => connection.type === "env")
.map((connection) => connection.name)
if (environment.length) {
yield* Effect.fail(
new Error(
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
),
)
}
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
}
const progress = spinner()
progress.start("Removing credential...")
yield* Effect.forEach(
credentials,
(connection) =>
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
{ concurrency: "unbounded", discard: true },
).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
)
outro("Done")
})
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
integrations: IntegrationInfo[],
target?: string,
) {
if (target) return yield* resolveIntegration(integrations, target)
const configured = integrations.filter((integration) =>
integration.connections.some((connection) => connection.type === "credential"),
)
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
const id = yield* prompt<string>(() =>
autocomplete({
message: "Select integration",
maxItems: 8,
options: configured.map((integration) => ({
value: integration.id,
label: integration.name,
hint: integration.connections
.filter((connection) => connection.type === "credential")
.map((connection) => connection.label)
.join(", "),
})),
}),
)
return yield* resolveIntegration(configured, id)
})
@@ -0,0 +1,73 @@
import { Effect } from "effect"
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { ServerConnection } from "../../../services/server-connection"
export const location = { directory: process.cwd() }
export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) {
const server = yield* ServerConnection.resolve(input)
return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
})
export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
return Effect.tryPromise({ try: run, catch: (cause) => cause })
}
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
yield* request((signal) => client.model.default({ location }, { signal }))
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
Effect.map((response) => response.data),
)
})
export const resolveIntegration = Effect.fn("cli.auth.resolve-integration")(function* (
integrations: IntegrationInfo[],
target: string,
) {
const normalized = target.replace(/\/+$/, "")
const byID = integrations.find((integration) => integration.id === normalized)
if (byID) return byID
const matches = integrations.filter((integration) => integration.name.toLowerCase() === normalized.toLowerCase())
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
return yield* Effect.fail(
new Error(
`Integration name "${target}" is ambiguous: ${matches.map((integration) => integration.id).join(", ")}`,
),
)
}
return yield* Effect.fail(new Error(`Integration not found: ${target}`))
})
export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
export function connectMethods(integration: IntegrationInfo) {
return integration.methods
.filter((method): method is ConnectMethod => method.type !== "env")
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
}
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
const normalized = target.toLowerCase()
const matches = methods.filter((method) => {
if (method.type === "key") return normalized === "key" || method.label?.toLowerCase() === normalized
return method.id === target || method.label.toLowerCase() === normalized
})
if (matches.length === 1) return matches[0]
if (matches.length > 1) return yield* Effect.fail(new Error(`Authentication method "${target}" is ambiguous`))
const available = methods.map(methodID).join(", ")
return yield* Effect.fail(
new Error(`Authentication method not found: ${target}${available ? `. Available: ${available}` : ""}`),
)
})
export function methodID(method: ConnectMethod) {
return method.type === "key" ? "key" : method.id
}
export function methodLabel(method: ConnectMethod) {
if (method.type === "key") return method.label ?? "API key"
return method.label
}
@@ -10,15 +10,17 @@ import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const requestedDirectory = Option.getOrUndefined(input.directory)
const requestedServer = Option.getOrUndefined(input.server)
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
server: requestedServer,
standalone: input.standalone,
mismatch: "replace",
onStart: (reason, previousVersion) => {
@@ -75,6 +77,7 @@ export default Runtime.handler(Commands, (input) =>
resolve: (spec) =>
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => {
const effect =
+9
View File
@@ -12,4 +12,13 @@ export const password = Config.redacted("OPENCODE_PASSWORD").pipe(
Config.withDefault(undefined),
)
export function session() {
return Object.fromEntries(
Object.entries(process.env).filter(
(entry): entry is [string, string] =>
entry[1] !== undefined && entry[0] !== "OPENCODE_PASSWORD" && entry[0] !== "OPENCODE_SERVER_PASSWORD",
),
)
}
export * as Env from "./env"
+2
View File
@@ -19,7 +19,9 @@ const Handlers = Runtime.handlers(Commands, {
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
list: () => import("./commands/handlers/auth/list"),
login: () => import("./commands/handlers/auth/login"),
logout: () => import("./commands/handlers/auth/logout"),
},
debug: {
agents: () => import("./commands/handlers/debug/agents"),
+4
View File
@@ -5,6 +5,7 @@ import { setTimeout } from "node:timers/promises"
import { readStdin } from "./util/io"
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
import { Env } from "./env"
export type MiniCommandInput = {
server: {
@@ -38,6 +39,7 @@ export async function runMini(input: MiniCommandInput) {
const directory = localDirectory()
const connection = createMiniConnection(input.server)
const sdk = connection.sdk
const environment = input.server.reconnect ? Env.session() : undefined
const requested = parseModel(input.model)
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined
const prepare = prepareTarget(input.agent)
@@ -55,6 +57,7 @@ export async function runMini(input: MiniCommandInput) {
fork: input.fork,
model: requested,
agent: input.agent,
environment,
prepare,
signal,
}).catch((error) => {
@@ -89,6 +92,7 @@ export async function runMini(input: MiniCommandInput) {
client,
location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent,
environment,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
: undefined,
+2
View File
@@ -9,6 +9,7 @@ import { parseSessionTargetModel, resolveSessionTarget } from "../session-target
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive"
import { UI } from "./ui"
import { Env } from "../env"
export type RunCommandInput = {
server: ServerConnection.Resolved
@@ -91,6 +92,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
? { providerID: explicit.model.providerID, id: explicit.model.modelID, variant: explicit.variant }
: undefined,
agent: input.agent,
environment: input.server.service ? Env.session() : undefined,
prepare: async (next) => {
const selected =
next.model ??
+7
View File
@@ -36,6 +36,7 @@ export async function resolveSessionTarget(input: {
fork?: boolean
model?: ModelRef
agent?: string
environment?: Readonly<Record<string, string>>
prepare: SessionTargetPreparation
signal?: AbortSignal
}): Promise<SessionTarget> {
@@ -70,6 +71,12 @@ export async function resolveSessionTarget(input: {
.catch((error) => {
throw new SessionTargetMutationError(error)
}))
if (input.environment !== undefined && location.workspaceID === undefined)
await input.client.session
.environment({ sessionID: session.id, variables: input.environment }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
return {
session,
location,
+69
View File
@@ -0,0 +1,69 @@
import { cancel, isCancel, log, outro } from "@clack/prompts"
import { Effect } from "effect"
import { EOL } from "node:os"
export class CancelledError extends Error {
constructor() {
super("Cancelled")
}
}
export function prompt<A>(run: () => Promise<A | symbol>) {
return Effect.tryPromise({
try: async () => {
const value = await run()
if (isCancel(value)) throw new CancelledError()
return value as A
},
catch: (cause) => cause,
})
}
export function requireInteractive(message: string) {
if (process.stdin.isTTY && process.stdout.isTTY) return Effect.void
return Effect.fail(new Error(message))
}
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
const { default: open } = yield* Effect.promise(() => import("open"))
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
})
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
return effect.pipe(
Effect.catchIf(
(error) => error instanceof CancelledError,
() =>
Effect.sync(() => {
cancel("Cancelled")
process.exitCode = 130
}),
),
Effect.catch((error) =>
Effect.sync(() => {
log.error(message(error))
outro("Failed")
process.exitCode = 1
}),
),
)
}
export function handleCommandErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
return effect.pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(message(error) + EOL)
process.exitCode = 1
}),
),
)
}
function message(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message
}
return String(error)
}
+252 -5
View File
@@ -1,20 +1,267 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
describe("auth command", () => {
test("registers login", async () => {
const [auth, login] = await Promise.all([cli(["auth", "--help"]), cli(["auth", "login", "--help"])])
test("registers authentication commands", async () => {
const [auth, list, login, logout] = await Promise.all([
cli(["auth", "--help"]),
cli(["auth", "list", "--help"]),
cli(["auth", "login", "--help"]),
cli(["auth", "logout", "--help"]),
])
expect(auth.exitCode).toBe(0)
expect(auth.stdout).toContain("list")
expect(auth.stdout).toContain("login")
expect(auth.stdout).toContain("Log in to a well-known authentication provider")
expect(auth.stdout).toContain("logout")
expect(auth.stdout).toContain("manage AI providers and credentials")
expect(auth.stdout).toContain("list providers and credentials")
expect(auth.stdout).toContain("log in to a provider")
expect(auth.stdout).toContain("log out from a configured provider")
expect(auth.stdout).not.toContain("connect")
expect(list.exitCode).toBe(0)
expect(list.stdout).toContain("opencode auth list [flags]")
expect(list.stdout).toContain("--format")
expect(login.exitCode).toBe(0)
expect(login.stdout).toContain("opencode auth login [flags] <url>")
expect(login.stdout).toContain("Well-known provider URL")
expect(login.stdout).toContain("opencode auth login [flags] [<target>]")
expect(login.stdout).toContain("Integration ID, name, or well-known provider URL")
expect(login.stdout).toContain("--method")
expect(logout.exitCode).toBe(0)
expect(logout.stdout).toContain("opencode auth logout [flags] [<target>]")
})
test("lists stored and environment connections", async () => {
const requests: string[] = []
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "anthropic",
name: "Anthropic",
methods: [],
connections: [
{ type: "credential", id: "cred_test", label: "default" },
{ type: "env", name: "ANTHROPIC_API_KEY" },
],
},
{ id: "openai", name: "OpenAI", methods: [], connections: [] },
]),
)
}
return new Response("Not found", { status: 404 })
}, requests)
const result = await cli(["auth", "list", "--format", "json", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(JSON.parse(result.stdout)).toEqual([
{
id: "anthropic",
name: "Anthropic",
connections: [
{ type: "credential", id: "cred_test", label: "default" },
{ type: "env", name: "ANTHROPIC_API_KEY" },
],
},
])
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
})
test("runs command authentication without interactive input", async () => {
const requests: Array<{ method: string; path: string }> = []
using server = authServer((request, url) => {
requests.push({ method: request.method, path: url.pathname })
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "company",
name: "Company",
methods: [{ id: "login", type: "command", label: "Company login", command: ["company", "login"] }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/company/connect/command" && request.method === "POST") {
return Response.json(located({ attemptID: "con_test", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "GET") {
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "company", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("Connected to Company")
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/company/connect/command" })
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/company/connect/command/con_test" })
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" })
})
test("completes automatic OAuth authentication", async () => {
const requests: Array<{ method: string; path: string }> = []
using server = authServer((request, url) => {
requests.push({ method: request.method, path: url.pathname })
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "openai",
name: "OpenAI",
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
return Response.json(
located({
attemptID: "con_oauth",
url: "https://example.com/authorize",
instructions: "Authorize OpenAI",
mode: "auto",
time: { created: 1, expires: 2 },
}),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("https://example.com/authorize")
expect(result.stdout).toContain("Connected to OpenAI")
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" })
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" })
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" })
})
test("settles the OAuth spinner when status polling fails", async () => {
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "openai",
name: "OpenAI",
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
return Response.json(
located({
attemptID: "con_oauth",
url: "https://example.com/authorize",
instructions: "Authorize OpenAI",
mode: "auto",
time: { created: 1, expires: 2 },
}),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
return new Response("Unavailable", { status: 500 })
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Authentication failed")
expect(result.stdout).toContain("Failed")
expect(result.stdout).not.toContain("\n at ")
})
test("removes stored credentials", async () => {
const removed: string[] = []
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "anthropic",
name: "Anthropic",
methods: [{ type: "key" }],
connections: [{ type: "credential", id: "cred_test", label: "default" }],
},
]),
)
}
if (url.pathname === "/api/credential/cred_test" && request.method === "DELETE") {
removed.push("cred_test")
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "logout", "anthropic", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("Disconnected from Anthropic")
expect(removed).toEqual(["cred_test"])
})
test("requires a target outside an interactive terminal", async () => {
const result = await cli(["auth", "login"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Pass an integration ID or name")
expect(result.stdout).not.toContain("Background service failed to start")
})
test("reports list connection failures without a stack trace", async () => {
using server = Bun.serve({ port: 0, fetch: () => new Response("Unavailable", { status: 503 }) })
const result = await cli(["auth", "list", "--server", server.url.toString()])
expect(result.exitCode).toBe(1)
expect(result.stdout).toBe("")
expect(result.stderr).toContain("did not provide a compatible V2 health response")
expect(result.stderr).not.toContain("\n at ")
})
})
function authServer(fetch: (request: Request, url: URL) => Response | Promise<Response>, requests?: string[]) {
return Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
requests?.push(url.pathname)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/model/default") return Response.json(located(null))
return fetch(request, url)
},
})
}
function health() {
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}
function located<T>(data: T) {
return {
location: {
directory: process.cwd(),
project: { id: "project", directory: process.cwd(), canonical: process.cwd() },
},
data,
}
}
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test"
import { Env } from "../src/env"
test("session environment omits server credentials", () => {
const previousPassword = process.env.OPENCODE_PASSWORD
const previousLegacyPassword = process.env.OPENCODE_SERVER_PASSWORD
const previousValue = process.env.OPENCODE_SESSION_ENV_TEST
process.env.OPENCODE_PASSWORD = "password"
process.env.OPENCODE_SERVER_PASSWORD = "legacy"
process.env.OPENCODE_SESSION_ENV_TEST = "included"
const environment = Env.session()
if (previousPassword === undefined) delete process.env.OPENCODE_PASSWORD
else process.env.OPENCODE_PASSWORD = previousPassword
if (previousLegacyPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
else process.env.OPENCODE_SERVER_PASSWORD = previousLegacyPassword
if (previousValue === undefined) delete process.env.OPENCODE_SESSION_ENV_TEST
else process.env.OPENCODE_SESSION_ENV_TEST = previousValue
expect(environment.OPENCODE_PASSWORD).toBeUndefined()
expect(environment.OPENCODE_SERVER_PASSWORD).toBeUndefined()
expect(environment.OPENCODE_SESSION_ENV_TEST).toBe("included")
})
+20
View File
@@ -55,6 +55,26 @@ describe("session target resolver", () => {
expect(target.session.id).toBe("ses_implicit")
})
test("attaches the terminal environment to the resolved local Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = session("ses_resume", "/session")
spyOn(client.session, "get").mockResolvedValue(selected)
spyOn(client.location, "get").mockResolvedValue(location("/session"))
const environment = spyOn(client.session, "environment").mockResolvedValue()
await resolveSessionTarget({
client,
session: selected.id,
environment: { PATH: "/terminal/bin" },
prepare,
})
expect(environment).toHaveBeenCalledWith({
sessionID: selected.id,
variables: { PATH: "/terminal/bin" },
})
})
test("prepares a fresh Session at the server Location before creation", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const order: string[] = []
+3 -12
View File
@@ -382,15 +382,6 @@ export type Endpoint5_31Output =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.viewed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID }
}
| {
readonly id: Event.ID
readonly created: number
@@ -923,9 +914,9 @@ export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messa
export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_35Input = { readonly sessionID: Session.ID }
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } }
export type Endpoint5_35Output = void
export type SessionViewOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -971,7 +962,7 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
readonly view: SessionViewOperation<E>
readonly environment: SessionEnvironmentOperation<E>
}
export type Endpoint6_0Input = {
@@ -614,7 +614,10 @@ const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34I
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
raw["session.view"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.environment"]({
params: { sessionID: input["sessionID"] },
payload: { variables: input["variables"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
@@ -646,7 +649,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
interrupt: Endpoint5_32(raw),
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
view: Endpoint5_35(raw),
environment: Endpoint5_35(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -80,8 +80,8 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionViewInput,
SessionViewOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
MessageListInput,
MessageListOutput,
ModelListInput,
@@ -898,11 +898,12 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
view: (input: SessionViewInput, requestOptions?: RequestOptions) =>
request<SessionViewOutput>(
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
request<SessionEnvironmentOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/view`,
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/environment`,
body: { variables: input["variables"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
+9 -36
View File
@@ -479,16 +479,6 @@ export type SessionRenamed = {
data: { sessionID: string; title: string }
}
export type SessionViewed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.viewed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string }
}
export type SessionDeleted = {
id: string
created: number
@@ -1520,7 +1510,7 @@ export type SessionInfo = {
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
time: { created: number; updated: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
@@ -1933,7 +1923,6 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionDeleted
| SessionForked
| SessionInboxDelivered
@@ -2024,7 +2013,6 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
| SessionForked
@@ -2488,13 +2476,7 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -2761,13 +2743,7 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -3034,13 +3010,7 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -3966,9 +3936,12 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionViewInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionEnvironmentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly variables: { readonly variables: { readonly [x: string]: string } }["variables"]
}
export type SessionViewOutput = void
export type SessionEnvironmentOutput = void
export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
-7
View File
@@ -814,13 +814,6 @@ export function createData(config: CreateDataInput) {
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
return
case "session.viewed":
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
return
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
+4 -12
View File
@@ -136,10 +136,8 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
test("session methods retain decoded Effect inputs and outputs", async () => {
const logQueries: Array<Record<string, string>> = []
const requests: Array<{ method: string; url: string }> = []
const httpClient = HttpClient.make((request) => {
const url = request.url
requests.push({ method: request.method, url })
if (url.includes("/log")) {
logQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
@@ -185,7 +183,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const created = yield* client.session.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.session.view({ sessionID: Session.ID.make("ses_test") })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.session.switchModel({
sessionID: Session.ID.make("ses_test"),
@@ -210,11 +207,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return { page, active, created, admitted, context, log, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
const listed = result.page.data[0]
if (!listed?.time.idle || !listed.time.viewed) throw new Error("Expected attention times")
expect(DateTime.toEpochMillis(listed.time.created)).toBe(1_717_171_717_000)
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
@@ -224,10 +217,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" })
expect(requests).toContainEqual({ method: "POST", url: "http://localhost:3000/api/session/ses_test/view" })
const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.model.selected" && logged[0].created).toBe(1_717_171_717_000)
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
1_717_171_717_000,
)
expect(logged.at(-1)).toEqual(synced)
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
})
@@ -266,8 +260,6 @@ const session = {
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
},
title: "Test",
location: { directory: "/tmp/project" },
-5
View File
@@ -539,7 +539,6 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } })
await client.session.view({ sessionID: "ses_test" })
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({
sessionID: "ses_test",
@@ -566,7 +565,6 @@ test("session methods use the public HTTP contract", async () => {
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
expect(page.data[0].time).toMatchObject({ idle: 1_717_171_717_002, viewed: 1_717_171_717_001 })
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
@@ -579,7 +577,6 @@ test("session methods use the public HTTP contract", async () => {
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
["GET", "http://localhost:3000/api/session/active"],
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/view"],
["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/prompt"],
@@ -654,8 +651,6 @@ const session = {
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
},
title: "Test",
location: { directory: "/tmp/project" },
+2 -22
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "94b6c496-ad84-426f-9d5d-3e1ac3ebfb56",
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
"ddl": [
{
"name": "account_state",
@@ -1350,26 +1350,6 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_idle",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_viewed",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
-2
View File
@@ -43,7 +43,6 @@ import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260815182818_session_viewed_state.js"
export const migrations = [
m00,
@@ -90,5 +89,4 @@ export const migrations = [
m41,
m42,
m43,
m44,
] satisfies DatabaseMigration.Migration[]
@@ -1,10 +1,43 @@
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import type { DatabaseMigration } from "../migration.js"
const previousV2Marker = "20260730195856_optional_session_title"
const migration: DatabaseMigration.Migration = {
id: "20260804233008_loose_psylocke",
up(tx) {
return Effect.gen(function* () {
// This marker identifies the completed pre-split V2 lineage. Its V2 tables
// are canonical, so rename them in place instead of replaying the V1 squash.
if (yield* tx.get(sql`SELECT id FROM migration WHERE id = ${previousV2Marker}`)) {
const v1Only = yield* tx.get(sql`
SELECT 1
FROM message
WHERE NOT EXISTS (
SELECT 1 FROM session_message WHERE session_message.session_id = message.session_id
)
LIMIT 1
`)
if (v1Only) return yield* Effect.die(new Error("Previous V2 database contains V1-only session history"))
yield* tx.run(`DROP INDEX IF EXISTS \`session_project_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_workspace_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_parent_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_time_suspended_idx\`;`)
yield* tx.run(`ALTER TABLE \`session\` RENAME TO \`session_v2\`;`)
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
)
yield* tx.run(`DROP TABLE IF EXISTS \`data_migration\`;`)
yield* tx.run(`DROP TABLE IF EXISTS \`session_context_epoch\`;`)
yield* tx.run(`DROP TABLE IF EXISTS \`session_input\`;`)
return
}
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`kv\` (
\`key\` text PRIMARY KEY,
@@ -1,14 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260815182818_session_viewed_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_idle\` integer;`)
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_viewed\` integer;`)
})
},
}
export default migration
-2
View File
@@ -209,8 +209,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`model\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_idle\` integer,
\`time_viewed\` integer,
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
+1 -1
View File
@@ -33,7 +33,7 @@ const layer = Layer.effect(
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
` Prefer ${global.tmp} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
"</env>",
].join("\n"),
),
+18 -12
View File
@@ -51,6 +51,7 @@ import { Global } from "@opencode-ai/util/global"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
// get project -> project.locations
//
@@ -165,7 +166,10 @@ export interface Interface {
input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly view: (input: { sessionID: SessionSchema.ID }) => Effect.Effect<void, NotFoundError>
readonly environment: (input: {
readonly sessionID: SessionSchema.ID
readonly variables?: SessionEnvironment.Variables
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
@@ -305,6 +309,7 @@ const layer = Layer.effect(
const locations = yield* LocationServiceMap.Service
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
@@ -448,16 +453,10 @@ const layer = Layer.effect(
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
view: Effect.fn("Session.view")(function* (input) {
const row = yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
if (row.idle === null || (row.viewed !== null && row.viewed >= row.idle)) return
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID })
environment: Effect.fn("Session.environment")(function* (input) {
yield* result.get(input.sessionID)
if (input.variables !== undefined) yield* environments.set(input.sessionID, input.variables)
return yield* environments.get(input.sessionID)
}),
remove: Effect.fn("Session.remove")(function* (sessionID) {
const session = yield* result.get(sessionID)
@@ -466,6 +465,7 @@ const layer = Layer.effect(
yield* closeTransport(session)
const children = yield* result.list({ parentID: sessionID })
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
yield* environments.clear(sessionID)
yield* bus.publish(SessionEvent.Deleted, { sessionID })
yield* bus.remove(sessionID)
}),
@@ -664,7 +664,12 @@ const layer = Layer.effect(
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell
.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
@@ -1114,6 +1119,7 @@ export const node = makeGlobalNode({
layer: layer.pipe(Layer.orDie),
deps: [
Job.node,
SessionEnvironment.node,
Database.node,
Bus.node,
Project.node,
+33
View File
@@ -0,0 +1,33 @@
export * as SessionEnvironment from "./environment.js"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionSchema } from "./schema.js"
export type Variables = Readonly<Record<string, string>>
export interface Interface {
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<Variables | undefined>
readonly set: (sessionID: SessionSchema.ID, variables: Variables) => Effect.Effect<void>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEnvironment") {}
const layer = Layer.sync(Service, () => {
const environments = new Map<SessionSchema.ID, Variables>()
return Service.of({
get: (sessionID) => Effect.sync(() => environments.get(sessionID)),
set: (sessionID, variables) =>
Effect.sync(() => {
environments.set(sessionID, { ...variables })
}),
clear: (sessionID) =>
Effect.sync(() => {
environments.delete(sessionID)
}),
})
})
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
-2
View File
@@ -53,8 +53,6 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
@@ -60,7 +60,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
"session.created": () => Effect.void,
"session.viewed": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return Effect.gen(function* () {
+3 -38
View File
@@ -391,30 +391,6 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
.pipe(Effect.orDie)
}
function projectIdle(
db: DatabaseService,
event:
| typeof SessionEvent.Execution.Succeeded.Type
| typeof SessionEvent.Execution.Failed.Type
| typeof SessionEvent.Execution.Interrupted.Type,
) {
return Effect.gen(function* () {
yield* run(db, event)
if (event.type === SessionEvent.Execution.Interrupted.type && event.data.reason === "shutdown") return
const time = event.created
yield* db
.update(SessionTable)
.set({
// Unread uses a strict timestamp comparison, so every terminal must advance even within one millisecond.
time_idle: sql`max(${time}, coalesce(${SessionTable.time_idle} + 1, ${time}))`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
})
}
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -536,17 +512,6 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) =>
db
.update(SessionTable)
.set({
time_viewed: sql`${SessionTable.time_idle}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
@@ -615,9 +580,9 @@ const layer = Layer.effectDiscard(
delivery: event.data.delivery,
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
Effect.gen(function* () {
yield* run(db, event)
-2
View File
@@ -56,8 +56,6 @@ export const SessionTable = sqliteTable(
variant?: string
}>(),
...Timestamps,
time_idle: integer(),
time_viewed: integer(),
time_compacting: integer(),
time_archived: integer(),
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
-4
View File
@@ -117,10 +117,6 @@ const layer = Layer.effect(
tokens_cache_write: input.data.info.tokens.cache.write,
time_created: DateTime.toEpochMillis(input.data.info.time.created),
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
time_viewed: input.data.info.time.viewed
? DateTime.toEpochMillis(input.data.info.time.viewed)
: null,
time_archived: input.data.info.time.archived
? DateTime.toEpochMillis(input.data.info.time.archived)
: null,
+19 -3
View File
@@ -15,6 +15,8 @@ import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select.js"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks.js"
import { SessionEnvironment } from "./session/environment.js"
import { SessionSchema } from "./session/schema.js"
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
@@ -76,6 +78,7 @@ export const layer = (options?: ShellSelect.Options) =>
const global = yield* Global.Service
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const environments = yield* SessionEnvironment.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
@@ -184,13 +187,18 @@ export const layer = (options?: ShellSelect.Options) =>
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const sessionID = input.metadata?.sessionID
const sessionEnvironment =
location.workspaceID === undefined && Schema.is(SessionSchema.ID)(sessionID)
? yield* environments.get(sessionID)
: undefined
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
...(sessionEnvironment ?? process.env),
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
@@ -324,7 +332,7 @@ export const layer = (options?: ShellSelect.Options) =>
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
Effect.catch(() => finish("exited")),
),
)
@@ -349,7 +357,15 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node],
deps: [
Bus.node,
Location.node,
Config.node,
Global.node,
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
],
})
}
+140 -22
View File
@@ -13,7 +13,10 @@ import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260815182818_session_viewed_state"
import previousV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain"
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -74,27 +77,6 @@ describe("DatabaseMigration", () => {
)
})
test("adds nullable attention state to existing sessions", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session_v2 (id text PRIMARY KEY, title text)`)
yield* db.run(sql`INSERT INTO session_v2 (id, title) VALUES ('ses_existing', 'Existing')`)
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
expect(yield* db.get(sql`SELECT id, title, time_idle, time_viewed FROM session_v2`)).toEqual({
id: "ses_existing",
title: "Existing",
time_idle: null,
time_viewed: null,
})
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: 1 })
}),
)
})
test("rejects a non-empty database without a session table", async () => {
await expect(
run(
@@ -150,6 +132,142 @@ describe("DatabaseMigration", () => {
)
})
test("preserves previous V2 state through the current migration lineage", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`PRAGMA foreign_keys = ON`)
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* db.run(sql`
INSERT INTO migration (id, time_completed)
VALUES ('20260730195856_optional_session_title', 1)
`)
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
yield* db.run(sql`
CREATE TABLE project_directory (
project_id text NOT NULL,
directory text NOT NULL,
type text,
strategy text,
time_created integer NOT NULL,
PRIMARY KEY (project_id, directory)
)
`)
yield* db.run(sql`
CREATE TABLE workspace (
id text PRIMARY KEY,
type text NOT NULL,
name text NOT NULL,
project_id text NOT NULL,
time_used integer NOT NULL
)
`)
yield* db.run(sql`
CREATE TABLE session (
id text PRIMARY KEY,
project_id text NOT NULL REFERENCES project(id) ON DELETE CASCADE,
workspace_id text,
parent_id text,
time_suspended integer
)
`)
yield* db.run(sql`CREATE INDEX session_project_idx ON session (project_id)`)
yield* db.run(sql`CREATE INDEX session_workspace_idx ON session (workspace_id)`)
yield* db.run(sql`CREATE INDEX session_parent_idx ON session (parent_id)`)
yield* db.run(
sql`CREATE INDEX session_time_suspended_idx ON session (time_suspended) WHERE "session"."time_suspended" IS NOT NULL`,
)
yield* db.run(sql`
CREATE TABLE session_message (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE,
data text NOT NULL
)
`)
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
yield* db.run(sql`
CREATE TABLE session_pending (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
)
`)
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(sql`
CREATE TABLE event (
id text PRIMARY KEY,
aggregate_id text NOT NULL REFERENCES event_sequence(aggregate_id) ON DELETE CASCADE,
seq integer NOT NULL,
created integer NOT NULL,
type text NOT NULL,
data text NOT NULL
)
`)
yield* db.run(sql`CREATE TABLE data_migration (name text PRIMARY KEY)`)
yield* db.run(sql`INSERT INTO project VALUES ('project')`)
yield* db.run(sql`INSERT INTO project_directory VALUES ('project', '/repo', 'main', NULL, 1)`)
yield* db.run(sql`INSERT INTO session VALUES ('session', 'project', NULL, NULL, NULL)`)
yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'session', '{"text":"preserved"}')`)
yield* db.run(sql`INSERT INTO session_pending VALUES ('pending', 'session')`)
yield* db.run(sql`INSERT INTO event_sequence VALUES ('session', 41)`)
yield* db.run(sql`INSERT INTO event VALUES ('event', 'session', 41, 1, 'session.text.ended.1', '{}')`)
yield* DatabaseMigration.applyOnly(db, [
previousV2Migration,
workspaceMigration,
executionClaimsMigration,
sessionInboxMigration,
worktreeMigration,
])
expect(yield* db.get(sql`SELECT id, resume_attempts FROM session_v2`)).toEqual({
id: "session",
resume_attempts: 0,
})
expect(yield* db.get(sql`SELECT id, data FROM session_message`)).toEqual({
id: "message",
data: '{"text":"preserved"}',
})
expect(yield* db.get(sql`SELECT id FROM session_pending`)).toEqual({ id: "pending" })
expect(yield* db.get(sql`SELECT seq FROM event_sequence`)).toEqual({ seq: 41 })
expect(yield* db.get(sql`SELECT id, seq FROM event`)).toEqual({ id: "event", seq: 41 })
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`),
).toBeUndefined()
expect(yield* db.get(sql`SELECT directory FROM worktree`)).toEqual({ directory: "/repo" })
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_message)`)).toContainEqual(
expect.objectContaining({ table: "session_v2" }),
)
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_pending)`)).toContainEqual(
expect.objectContaining({ table: "session_v2" }),
)
}),
)
})
test("rejects previous V2 databases with V1-only session history", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* db.run(sql`
INSERT INTO migration (id, time_completed)
VALUES ('20260730195856_optional_session_title', 1)
`)
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL)`)
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
yield* db.run(sql`INSERT INTO session VALUES ('session')`)
yield* db.run(sql`INSERT INTO message VALUES ('message', 'session')`)
expect((yield* Effect.exit(DatabaseMigration.applyOnly(db, [previousV2Migration])))._tag).toBe("Failure")
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session",
})
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${previousV2Migration.id}`)).toBeUndefined()
}),
)
})
test("copies project directories into worktrees without removing the old table", async () => {
await run(
Effect.gen(function* () {
@@ -51,7 +51,7 @@ describe("InstructionBuiltIns", () => {
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
+11 -19
View File
@@ -679,13 +679,18 @@ describe("Session.create", () => {
const created = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
yield* session.environment({ sessionID: created.id, variables: { OPENCODE_SESSION_ENV_TEST: "attached" } })
yield* session.shell({ sessionID: created.id, command: "echo hello" })
const command =
process.platform === "win32"
? "[Console]::Out.Write($env:OPENCODE_SESSION_ENV_TEST)"
: 'printf %s "$OPENCODE_SESSION_ENV_TEST"'
yield* session.shell({ sessionID: created.id, command })
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", command: "echo hello", status: "exited", exit: 0 })
expect(shell?.output?.output).toContain("hello")
expect(shell).toMatchObject({ type: "shell", command, status: "exited", exit: 0 })
expect(shell?.output?.output).toContain("attached")
expect(shell?.output?.truncated).toBe(false)
expect(shell?.time.completed).toBeDefined()
}),
@@ -840,15 +845,7 @@ describe("SessionTransfer", () => {
const imported = yield* transfer.import({
data: {
info: {
...template,
id: sessionID,
time: {
...template.time,
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(150),
},
},
info: { ...template, id: sessionID },
messages: [
{
id: sourceMessageID,
@@ -871,18 +868,13 @@ describe("SessionTransfer", () => {
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(messages).toMatchObject([
{ id: sourceMessageID, type: "user", text: "Imported message" },
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
const exported = yield* transfer.export({ sessionID })
expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(exported.messages).toEqual(messages)
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(sanitized.messages).toMatchObject([
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
@@ -0,0 +1,30 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Session } from "@opencode-ai/core/session"
import { SessionEnvironment } from "@opencode-ai/core/session/environment"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(SessionEnvironment.node))
describe("SessionEnvironment", () => {
it.effect("stores replacement snapshots by session", () =>
Effect.gen(function* () {
const environments = yield* SessionEnvironment.Service
const first = Session.ID.make("ses_environment_first")
const second = Session.ID.make("ses_environment_second")
yield* environments.set(first, { TOOLCHAIN: "first", PATH: "/first/bin" })
yield* environments.set(second, { TOOLCHAIN: "second" })
yield* environments.set(first, { TOOLCHAIN: "updated" })
expect(yield* environments.get(first)).toEqual({ TOOLCHAIN: "updated" })
expect(yield* environments.get(second)).toEqual({ TOOLCHAIN: "second" })
yield* environments.clear(first)
expect(yield* environments.get(first)).toBeUndefined()
expect(yield* environments.get(second)).toEqual({ TOOLCHAIN: "second" })
}),
)
})
@@ -12,6 +12,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionEnvironment } from "@opencode-ai/core/session/environment"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
@@ -32,6 +33,7 @@ const it = testEffect(
Bus.node,
SessionProjector.node,
SessionStore.node,
SessionEnvironment.node,
Session.node,
LocationServiceMap.node,
]),
@@ -50,6 +52,8 @@ describe("Session.remove", () => {
const session = yield* Session.Service
const parent = yield* session.create({ location })
const child = yield* session.create({ parentID: parent.id })
yield* session.environment({ sessionID: parent.id, variables: { SESSION_ENV: "parent" } })
yield* session.environment({ sessionID: child.id, variables: { SESSION_ENV: "child" } })
yield* (yield* LocationServiceMap.Service).contextEffect(location)
closed.length = 0
@@ -57,6 +61,9 @@ describe("Session.remove", () => {
expect((yield* session.list()).data).toEqual([])
expect(closed).toEqual([parent.id, child.id])
const environments = yield* SessionEnvironment.Service
expect(yield* environments.get(parent.id)).toBeUndefined()
expect(yield* environments.get(child.id)).toBeUndefined()
expect(yield* Effect.result(session.get(parent.id))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.result(session.get(child.id))).toMatchObject({ _tag: "Failure" })
}),
-174
View File
@@ -1,174 +0,0 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect, Layer } from "effect"
import { asc, eq } from "drizzle-orm"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("Session.view", () => {
it.effect("copies the latest idle time without changing session recency", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
expect(created.time.idle).toBeUndefined()
expect(created.time.viewed).toBeUndefined()
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time.viewed).toBeUndefined()
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
const idle = yield* session.get(created.id)
expect(idle.time.idle).toBeDefined()
expect(idle.time.viewed).toBeUndefined()
expect(idle.time.updated).toEqual(created.time.updated)
yield* session.view({ sessionID: created.id })
const viewed = yield* session.get(created.id)
if (!viewed.time.idle || !viewed.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(viewed.time.viewed).toEqual(viewed.time.idle)
expect(viewed.time.updated).toEqual(created.time.updated)
expect(
yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, created.id))
.get(),
).toEqual({
idle: DateTime.toEpochMillis(viewed.time.idle),
viewed: DateTime.toEpochMillis(viewed.time.viewed),
})
expect((yield* session.list()).data.find((item) => item.id === created.id)?.time).toEqual(viewed.time)
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time).toEqual(viewed.time)
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
const unread = yield* session.get(created.id)
if (!unread.time.idle || !unread.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(unread.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(unread.time.viewed))
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time.viewed).toEqual(unread.time.idle)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "shutdown" })
expect((yield* session.get(created.id)).time.idle).toEqual(unread.time.idle)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "user" })
const interrupted = yield* session.get(created.id)
if (!interrupted.time.idle || !interrupted.time.viewed)
return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(interrupted.time.idle)).toBeGreaterThan(
DateTime.toEpochMillis(interrupted.time.viewed),
)
expect(
(yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.all()).filter((event) => event.type === Bus.versionedType(SessionEvent.Viewed.type, 1)),
).toHaveLength(2)
}),
)
it.effect("rejects an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const sessionID = Session.ID.make("ses_missing_view")
expect(yield* Effect.flip(session.view({ sessionID }))).toEqual(new Session.NotFoundError({ sessionID }))
}),
)
it.effect("replays viewed state into a fresh database", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const sourceDb = (yield* Database.Service).db
const created = yield* session.create({ id: Session.ID.make("ses_view_replay"), location })
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
yield* session.view({ sessionID: created.id })
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
const expected = yield* session.get(created.id)
if (!expected.time.idle || !expected.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
const expectedIdle = DateTime.toEpochMillis(expected.time.idle)
const expectedViewed = DateTime.toEpochMillis(expected.time.viewed)
const serialized = (yield* sourceDb
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}))
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
[Bus.node, Bus.configured({ persist: true })],
],
)
yield* Effect.gen(function* () {
const db = (yield* Database.Service).db
const targetBus = yield* Bus.Service
const store = yield* SessionStore.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* Effect.forEach(serialized, (event) => targetBus.replay(event), { discard: true })
expect((yield* store.get(created.id))?.time).toEqual(expected.time)
expect(expected.time.updated).toEqual(created.time.updated)
expect(expectedIdle).toBeGreaterThan(expectedViewed)
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
)
})
+64
View File
@@ -260,6 +260,36 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
productionIt.live(
"uses the session environment instead of the server environment",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const sessions = yield* Session.Service
yield* sessions.environment({
sessionID,
variables: { OPENCODE_SESSION_ENV_TEST: "from-session" },
})
const command = isWindows
? "[Console]::Out.Write($env:OPENCODE_SESSION_ENV_TEST)"
: 'printf %s "$OPENCODE_SESSION_ENV_TEST"'
const settled = yield* executeTool(registry, call({ command }))
expect(settled.status).toBe("completed")
expect(settled.content?.[0]).toEqual({ type: "text", text: "from-session" })
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -782,6 +812,40 @@ describe("ShellTool", () => {
),
)
if (!isWindows) {
it.live("settles a shell terminated by an external signal", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const settled = yield* executeTool(
registry,
call({ command: idleCommand, background: true }, "call-external-signal"),
)
const shellID = settled.metadata?.shellID
expect(typeof shellID).toBe("string")
if (typeof shellID !== "string") return
const id = ShellSchema.ID.make(shellID)
const info = yield* shell.get(id)
expect(typeof info.pid).toBe("number")
if (info.pid === undefined) return
process.kill(-info.pid, "SIGTERM")
const result = yield* shell.wait(id).pipe(Effect.timeoutOption(Duration.seconds(1)))
expect(result._tag).toBe("Some")
if (result._tag === "Some") expect(result.value.status).toBe("exited")
expect((yield* shell.list()).map((item) => item.id)).not.toContain(id)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
}
it.live("backgrounds a foreground command when the session is signaled", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
-2
View File
@@ -60,8 +60,6 @@ const session = (
model: null,
time_created: 1,
time_updated: 2,
time_idle: null,
time_viewed: null,
time_compacting: 3,
time_archived: null,
time_suspended: null,
+25 -82
View File
@@ -4127,10 +4127,10 @@
"summary": "Get session message"
}
},
"/api/session/{sessionID}/view": {
"post": {
"/api/session/{sessionID}/environment": {
"put": {
"tags": ["session"],
"operationId": "v2.session.view",
"operationId": "v2.session.environment",
"parameters": [
{
"name": "sessionID",
@@ -4182,8 +4182,28 @@
}
}
},
"description": "Mark the latest recorded idle transition as viewed.",
"summary": "View session"
"description": "Replace the process environment used by local shell commands for this session.",
"summary": "Set session environment",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"variables": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["variables"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/message": {
@@ -12194,12 +12214,6 @@
"updated": {
"type": "number"
},
"idle": {
"type": "number"
},
"viewed": {
"type": "number"
},
"archived": {
"type": "number"
}
@@ -14341,71 +14355,6 @@
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.viewed": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["session.viewed"]
},
"durable": {
"type": "object",
"properties": {
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer",
"allOf": [
{
"minimum": 0
}
]
},
"version": {
"type": "number",
"enum": [1]
}
},
"required": ["aggregateID", "seq", "version"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.deleted": {
"type": "object",
"properties": {
@@ -17426,9 +17375,6 @@
{
"$ref": "#/components/schemas/session.renamed"
},
{
"$ref": "#/components/schemas/session.viewed"
},
{
"$ref": "#/components/schemas/session.deleted"
},
@@ -22797,9 +22743,6 @@
{
"$ref": "#/components/schemas/session.renamed"
},
{
"$ref": "#/components/schemas/session.viewed"
},
{
"$ref": "#/components/schemas/session.usage.updated"
},
-1
View File
@@ -33,7 +33,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:"
}
}
+1 -2
View File
@@ -1,9 +1,8 @@
import { OpenApi } from "effect/unstable/httpapi"
import { format } from "prettier"
import { fileURLToPath } from "url"
import { ClientApi } from "../src/client.js"
const document = await format(JSON.stringify(OpenApi.fromApi(ClientApi), null, 2), { parser: "json", printWidth: 120 })
const document = JSON.stringify(OpenApi.fromApi(ClientApi), null, 2) + "\n"
const target = fileURLToPath(new URL("../openapi.json", import.meta.url))
if (process.argv.includes("--check")) {
+5 -4
View File
@@ -694,15 +694,16 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
),
)
.add(
HttpApiEndpoint.post("session.view", "/api/session/:sessionID/view", {
HttpApiEndpoint.put("session.environment", "/api/session/:sessionID/environment", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ variables: Schema.Record(Schema.String, Schema.String) }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.view",
summary: "View session",
description: "Mark the latest recorded idle transition as viewed.",
identifier: "v2.session.environment",
summary: "Set session environment",
description: "Replace the process environment used by local shell commands for this session.",
}),
),
)
-8
View File
@@ -105,13 +105,6 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const Viewed = Event.durable({
type: "session.viewed",
...options,
schema: Base,
})
export type Viewed = typeof Viewed.Type
export const UsageRecorded = Event.durable({
type: "session.usage.recorded",
...options,
@@ -587,7 +580,6 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
Viewed,
UsageUpdated,
Deleted,
Forked,
-2
View File
@@ -40,8 +40,6 @@ export const Info = Schema.Struct({
time: Schema.Struct({
created: DateTimeUtcFromMillis,
updated: DateTimeUtcFromMillis,
idle: DateTimeUtcFromMillis.pipe(optional),
viewed: DateTimeUtcFromMillis.pipe(optional),
archived: DateTimeUtcFromMillis.pipe(optional),
}),
title: Schema.String.pipe(optional),
+9 -21
View File
@@ -54,29 +54,17 @@ describe("contract hygiene", () => {
}),
).toEqual({ text: "completed" })
const info = Session.Info.make({
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
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),
idle: undefined,
viewed: undefined,
},
title: undefined,
location: { directory: AbsolutePath.make("/project") },
})
const encoded = Schema.encodeSync(Session.Info)(info)
expect(encoded).not.toHaveProperty("title")
expect(encoded.time).toEqual({ created: 0, updated: 0 })
expect(
Schema.encodeSync(Session.Info)({
...info,
time: { ...info.time, idle: DateTime.makeUnsafe(2), viewed: DateTime.makeUnsafe(1) },
}).time,
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
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) },
title: undefined,
location: { directory: AbsolutePath.make("/project") },
}),
).not.toHaveProperty("title")
})
test("session inbox items omit the internal enqueue sequence", () => {
@@ -83,7 +83,6 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.viewed.1",
"session.usage.recorded.1",
"session.forked.2",
"session.inbox.delivered.1",
+4 -4
View File
@@ -181,9 +181,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
)
.handle(
"session.view",
"session.remove",
Effect.fn(function* (ctx) {
yield* session.view({ sessionID: ctx.params.sessionID }).pipe(
yield* session.remove(ctx.params.sessionID).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
@@ -197,9 +197,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
)
.handle(
"session.remove",
"session.environment",
Effect.fn(function* (ctx) {
yield* session.remove(ctx.params.sessionID).pipe(
yield* session.environment({ sessionID: ctx.params.sessionID, variables: ctx.payload.variables }).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
-30
View File
@@ -52,36 +52,6 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
}).pipe(Effect.scoped),
)
it.live("serves the session view operation and missing-session error", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
const created = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
).then((response) => response.json()),
)
if (typeof created !== "object" || created === null || !("data" in created))
return yield* Effect.die(new Error("Expected a session response"))
const data = created.data
if (typeof data !== "object" || data === null || !("id" in data) || typeof data.id !== "string")
return yield* Effect.die(new Error("Expected a session ID"))
const viewed = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${data.id}/view`, { method: "POST" })),
)
expect(viewed.status).toBe(204)
const missing = yield* Effect.promise(() =>
handler(new Request("http://opencode.local/api/session/ses_missing_view/view", { method: "POST" })),
)
expect(missing.status).toBe(404)
}).pipe(Effect.scoped),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
@@ -0,0 +1,47 @@
import { describe, expect, mock, test } from "bun:test"
mock.module("@solidjs/router", () => ({ query: (load: () => unknown) => load }))
const { buildModelCatalog, findModelCatalogEntry } = await import("./model-catalog")
describe("model catalog pricing", () => {
test("prefers OpenCode Go pricing over a zero-cost coding plan with the same model id", () => {
const catalog = buildModelCatalog(
{
models: {
"zhipuai/glm-5.3": {
id: "zhipuai/glm-5.3",
name: "GLM-5.3",
},
},
},
{
"opencode-go": {
id: "opencode-go",
models: {
"glm-5.3": {
id: "glm-5.3",
cost: { input: 1.4, output: 4.4, cache_read: 0.26 },
},
},
},
"zhipuai-coding-plan": {
id: "zhipuai-coding-plan",
models: {
"glm-5.3": {
id: "glm-5.3",
cost: { input: 0, output: 0, cache_read: 0 },
},
},
},
},
)
expect(findModelCatalogEntry(catalog, "glm-5.3")?.cost).toEqual({
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: undefined,
})
})
})
@@ -119,7 +119,7 @@ export function catalogSlug(value: string) {
.replace(/-{2,}/g, "-")
}
function buildModelCatalog(payload: unknown, pricingPayload?: unknown, labPayload?: unknown): ModelCatalog {
export function buildModelCatalog(payload: unknown, pricingPayload?: unknown, labPayload?: unknown): ModelCatalog {
const costs = readCatalogCosts(pricingPayload)
const labDescriptions = readCatalogLabDescriptions(payload, pricingPayload, labPayload)
const models = readCatalogModels(payload)
@@ -129,6 +129,7 @@ function buildModelCatalog(payload: unknown, pricingPayload?: unknown, labPayloa
cost:
costs.get(catalogIdKey(model.id)) ??
costs.get(`${model.lab}/${model.slug}`) ??
costs.get(`opencode-go/${model.slug}`) ??
costs.get(model.slug) ??
model.cost,
}))
+14
View File
@@ -41,6 +41,7 @@ import {
useTuiApp,
useTuiPaths,
useTuiStartup,
useTuiTerminalEnvironment,
type TuiApp,
} from "./context/runtime"
import { DialogProvider, useDialog } from "./ui/dialog"
@@ -185,6 +186,7 @@ export type TuiInput = {
args: Args
config: Config.Interface
packages: PackageResolver
environment?: Readonly<Record<string, string>>
terminalHandoff?: () => Promise<
| {
readonly renderer: CliRenderer
@@ -332,6 +334,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: process.env.DISPLAY
? "x11"
: undefined,
variables: input.environment,
}}
>
<TuiStartupProvider
@@ -480,6 +483,17 @@ function App(props: { pair?: DialogPairCredentials }) {
const promptRef = usePromptRef()
const plugins = usePlugin()
const clipboard = useClipboard()
const terminalEnvironment = useTuiTerminalEnvironment()
createEffect(() => {
if (client.connection.status() !== "connected") return
if (route.data.type !== "session") return
const session = data.session.get(route.data.sessionID)
if (!session) return
if (session.location.workspaceID !== undefined || terminalEnvironment.variables === undefined) return
void client.api.session
.environment({ sessionID: session.id, variables: terminalEnvironment.variables })
.catch(toast.error)
})
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
})
+13 -1
View File
@@ -452,7 +452,6 @@ export function Prompt(props: PromptProps) {
title: "Queue prompt",
name: "prompt.queue",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
@@ -1206,6 +1205,19 @@ export function Prompt(props: PromptProps) {
sessionID = created.id
session = created
if (created.location.workspaceID === undefined && terminalEnvironment.variables !== undefined) {
const error = await client.api.session
.environment({ sessionID, variables: terminalEnvironment.variables })
.then(
() => undefined,
(error) => error,
)
if (error) {
if (finishMoveProgress) move.finishSubmit()
toast.show({ title: "Failed to set session environment", message: errorMessage(error), variant: "error" })
return true
}
}
}
// Capture mode before it gets reset
+8 -12
View File
@@ -23,7 +23,7 @@ import {
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabShortcutLabel,
sessionTabNumberLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
type SessionTab,
@@ -426,7 +426,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const value = session()
return value ? data.project.get(value.projectID) : undefined
})
const numberWidth = () => 2
const numberWidth = () => Math.max(2, String(items().length).length)
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
@@ -657,14 +657,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
backgroundColor={pulseBackground()}
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
<text
width={numberWidth()}
width={numberWidth() + 1}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{sessionTabShortcutLabel(index())}
{sessionTabNumberLabel(index()).padStart(numberWidth())}
</text>
<text
width={titleWidth()}
@@ -1040,8 +1040,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
const numberWidth = () => Math.max(2, String(items().length).length)
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
@@ -1141,11 +1140,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={1} selectable={false}>
{" "}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
</text>
<text
width={availableTitleWidth()}
+1 -1
View File
@@ -178,7 +178,7 @@ export const Definitions = {
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
"prompt.submit": keybind("none", "Submit prompt"),
"prompt.queue": keybind("alt+return", "Queue prompt"),
"prompt.queue": keybind("<leader>return", "Queue prompt"),
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
"prompt.images.view": keybind("<leader>i", "View image attachments"),
"prompt.skills": keybind("none", "Open skill selector"),
+1 -1
View File
@@ -163,7 +163,7 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_queue: keybind("<leader>return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_images_view: keybind("<leader>i", "View image attachments"),
prompt_skills: keybind("none", "Open skill selector"),
+1
View File
@@ -17,6 +17,7 @@ export type TuiTerminalEnvironment = Readonly<{
platform: string
multiplexer?: "tmux" | "screen"
displayServer?: "wayland" | "x11"
variables?: Readonly<Record<string, string>>
}>
export type TuiStartup = Readonly<{
@@ -7,10 +7,8 @@ export type SessionTabUnread = "activity" | "error"
export const NEW_SESSION_TAB_TITLE = "New session"
export function sessionTabShortcutLabel(index: number) {
if (index >= 0 && index < 9) return String(index + 1)
if (index === 9) return "0"
return "·"
export function sessionTabNumberLabel(index: number) {
return String(index + 1)
}
export function sessionTabDetail(
+37 -23
View File
@@ -25,12 +25,12 @@ import {
type ClosedSessionTab,
type SessionTab,
type SessionTabHistory,
type SessionTabUnread,
} from "./session-tabs-model"
type TabsState = {
tabs: SessionTab[]
// Read only long enough to remove the former client-owned state from persisted tab files.
unread?: Record<string, unknown>
unread: Record<string, SessionTabUnread>
}
type PersistedState = {
@@ -43,7 +43,7 @@ type ScrollAnchor = {
screenY: number
}
const empty = (): TabsState => ({ tabs: [] })
const empty = (): TabsState => ({ tabs: [], unread: {} })
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
const TAB_PREFETCH_DELAY = 300
@@ -60,7 +60,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const paths = useTuiPaths()
const renderer = useRenderer()
const enabled = () => config.tabs.enabled
// Focus reporting emits transitions, so an interactive launch may acknowledge viewed sessions until its first blur.
// Focus reporting emits transitions, so an interactive launch owns unread state until its first blur.
const [focused, setFocused] = createSignal(true)
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
// mutating in place, which per-row animations and drag state depend on.
@@ -105,20 +105,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
}
const isUnread = (sessionID: string) => {
const info = data.session.get(sessionID)
return info?.time.idle !== undefined && (info.time.viewed === undefined || info.time.idle > info.time.viewed)
}
const family = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
return members.length > 0 ? members : [session]
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {}),
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
@@ -129,17 +125,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}, false)
const status = (sessionID: string) => {
const session = root(sessionID)
const members = family(session)
const members = data.session.family(session)
const family = members.length > 0 ? members : [session]
return {
unread: members.some(isUnread) ? ("activity" as const) : undefined,
unread: state().unread[session],
promptPulse: promptPulses()[session] ?? 0,
attention: members.some(
attention: family.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
}
}
function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled() || !focused()) return
const session = root(sessionID)
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
if (state().unread[session] === unread) return
update((draft) => {
if (!draft.tabs.some((tab) => tab.sessionID === session)) return
draft.unread[session] = unread
})
}
createEffect(() => {
if (!enabled()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
@@ -162,9 +170,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
createEffect(() => {
if (!enabled() || !focused()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const unread = family(route.data.sessionID).filter(isUnread)
if (unread.length === 0) return
void Promise.allSettled(unread.map((id) => client.api.session.view({ sessionID: id })))
const sessionID = root(route.data.sessionID)
if (!state().unread[sessionID]) return
update((draft) => {
delete draft.unread[sessionID]
})
})
createEffect(() => {
@@ -174,7 +184,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
update((draft) => {
const next = normalize(draft)
draft.tabs = next.tabs
delete draft.unread
draft.unread = next.unread
})
})
@@ -195,7 +205,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const sessionIDs = signature.split("\n")
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID, { children: true })))
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
if (stale) return
const locations = new Map(
sessionIDs
@@ -229,6 +239,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
onCleanup(
event.on("session.moved", (evt) => {
if (!enabled() || !state().tabs.some((tab) => tab.sessionID === root(evt.data.sessionID))) return
@@ -264,6 +277,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
history = previous.history
update((draft) => {
draft.tabs = closeSessionTab(draft.tabs, target).tabs
delete draft.unread[target]
})
setPromptPulses((pulses) => {
if (pulses[target] === undefined) return pulses
@@ -359,7 +373,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
Boolean(status(tab.sessionID).unread || status(tab.sessionID).attention),
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
+1
View File
@@ -1050,6 +1050,7 @@ export function createPromptState(input: PromptInput): PromptState {
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
palette: true,
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
+1 -1
View File
@@ -595,7 +595,7 @@ export function RunFooterView(props: RunFooterViewProps) {
{
id: "session.queued_prompts",
title: "View queued prompts",
group: "Session",
group: "Prompt",
run: openQueuedMenu,
},
],
+1 -1
View File
@@ -1062,7 +1062,7 @@ export function Session(props: { verticalTabsWidth: number }) {
{
title: "View queued prompts",
id: "session.queued_prompts",
group: "Session",
group: "Prompt",
enabled: queuedPrompts().length > 0,
run: openQueuedPrompts,
},
+1
View File
@@ -107,6 +107,7 @@ test("preserves migrated v1 keybind defaults", () => {
const pairs = [
["app.exit", "app_exit"],
["prompt.paste", "input_paste"],
["prompt.queue", "prompt_queue"],
["session.delete", "session_delete"],
["session.list", "session_list"],
["agent.list", "agent_list"],
@@ -13,7 +13,7 @@ import {
sessionTabComplete,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabShortcutLabel,
sessionTabNumberLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
@@ -25,8 +25,8 @@ describe("session tabs", () => {
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
})
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
test("labels tabs by ordinal", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabNumberLabel(index))).toEqual([
"1",
"2",
"3",
@@ -36,9 +36,9 @@ describe("session tabs", () => {
"7",
"8",
"9",
"0",
"·",
"·",
"10",
"11",
"12",
])
})
+51 -99
View File
@@ -35,8 +35,6 @@ async function renderSessionTabs(
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
sessionParents?: Record<string, string>
sessionTimes?: Record<string, { idle?: number; viewed?: number }>
newLocation?: "launch" | "inherit"
},
) {
@@ -55,13 +53,9 @@ async function renderSessionTabs(
}
const events = createEventStream()
const sessions: string[] = []
const views: string[] = []
const locations: string[] = []
const vcsLocations: string[] = []
const sessionTimes = Object.fromEntries(
Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
)
const calls = createFetch(async (url, request) => {
const calls = createFetch(async (url) => {
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
locations.push(requested)
@@ -78,13 +72,6 @@ async function renderSessionTabs(
data: { branch: { current: "main", default: "main" } },
})
}
const viewed = url.pathname.match(/^\/api\/session\/([^/]+)\/view$/)?.[1]
if (viewed && request.method === "POST") {
views.push(viewed)
const time = (sessionTimes[viewed] ??= {})
time.viewed = time.idle
return new Response(null, { status: 204 })
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -92,13 +79,12 @@ async function renderSessionTabs(
return json({
data: {
id: sessionID,
parentID: options?.sessionParents?.[sessionID],
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0, ...sessionTimes[sessionID] },
time: { created: 0, updated: 0 },
},
})
}, events)
@@ -152,13 +138,9 @@ async function renderSessionTabs(
route,
data,
sessions,
views,
locations,
vcsLocations,
state,
setSessionTime(sessionID: string, time: { idle?: number; viewed?: number }) {
sessionTimes[sessionID] = time
},
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
focus: () => app.renderer.emit("focus"),
blur: () => app.renderer.emit("blur"),
@@ -171,6 +153,14 @@ async function renderSessionTabs(
}
}
const executionSucceeded = (sessionID: string): OpenCodeEvent => ({
id: `evt_done_${sessionID}`,
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID },
})
test("loads persisted tab metadata concurrently on connect", async () => {
let release!: () => void
const sessionGate = new Promise<void>((resolve) => (release = resolve))
@@ -240,10 +230,10 @@ test("stores session tabs for the current working directory by default", async (
const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0)
const stored = await Bun.file(file).json()
expect(stored.global).toEqual({ tabs: [] })
expect(stored.global).toEqual({ tabs: [], unread: {} })
expect(Object.keys(stored.cwd)).toEqual([directory])
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
expect(stored.cwd[directory]).not.toHaveProperty("unread")
expect(stored.cwd[directory].unread).toEqual({})
} finally {
await setup.destroy()
}
@@ -266,85 +256,47 @@ test("keeps scroll anchors for open session tabs", async () => {
}
})
test("derives unread state from server session times", async () => {
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionTimes: { second: { idle: 2 } },
})
test("only the foreground TUI mutates unread state", async () => {
await using temporary = await tmpdir()
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
try {
await wait(() => setup.tabs.status("second").unread === "activity")
expect(setup.tabs.status("first").unread).toBeUndefined()
foreground = await renderSessionTabs("first", { state: temporary.path, persisted: ["first", "second"] })
background = await renderSessionTabs("second", { state: temporary.path })
foreground.focus()
background.blur()
await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2, 2_000, "shared tabs")
const firstDone = executionSucceeded("first")
foreground.emit(firstDone)
background.emit(firstDone)
await Promise.all([foreground.flush(), background.flush()])
expect(foreground.tabs.status("first").unread).toBeUndefined()
expect(background.tabs.status("first").unread).toBeUndefined()
const secondDone = executionSucceeded("second")
foreground.emit(secondDone)
background.emit(secondDone)
await wait(
() =>
foreground?.tabs.status("second").unread === "activity" &&
background?.tabs.status("second").unread === "activity",
10_000,
"shared unread activity",
)
foreground.tabs.select("second")
await wait(
() =>
foreground?.tabs.status("second").unread === undefined &&
background?.tabs.status("second").unread === undefined,
10_000,
"shared unread clearing",
)
} finally {
await setup.destroy()
}
})
test("refreshes server session times after terminal events", async () => {
const setup = await renderSessionTabs("first", { home: true, persisted: ["first"] })
try {
setup.setSessionTime("first", { idle: 2 })
setup.emit({
id: "evt_done_first",
created: 2,
type: "session.execution.succeeded",
durable: { aggregateID: "first", seq: 1, version: 1 },
data: { sessionID: "first" },
})
await wait(() => setup.tabs.status("first").unread === "activity")
} finally {
await setup.destroy()
}
})
test("views a selected unread session only while focused", async () => {
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first"],
sessionTimes: { first: { idle: 2 } },
})
try {
setup.blur()
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first" && setup.tabs.status("first").unread === "activity")
await Bun.sleep(20)
expect(setup.views).toEqual([])
setup.focus()
await wait(() => setup.views.includes("first"))
setup.emit({
id: "evt_viewed_first",
created: 3,
type: "session.viewed",
durable: { aggregateID: "first", seq: 2, version: 1 },
data: { sessionID: "first" },
})
await wait(() => setup.tabs.status("first").unread === undefined)
} finally {
await setup.destroy()
}
})
test("views unread child sessions through their root tab", async () => {
const setup = await renderSessionTabs("root", {
home: true,
persisted: ["root"],
sessionParents: { child: "root" },
sessionTimes: { child: { idle: 2 } },
})
try {
setup.blur()
await setup.data.session.sync("child")
await wait(() => setup.tabs.status("root").unread === "activity")
setup.route.navigate({ type: "session", sessionID: "root" })
await Bun.sleep(20)
expect(setup.views).toEqual([])
setup.focus()
await wait(() => setup.views.includes("child"))
expect(setup.views).not.toContain("root")
} finally {
await setup.destroy()
if (foreground) await foreground.destroy()
if (background) await background.destroy()
}
})
+4 -2
View File
@@ -981,7 +981,8 @@ test("direct footer steers the oldest queued prompt from an empty composer", asy
try {
await app.renderOnce()
app.mockInput.pressEnter({ meta: true })
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressEnter()
await Bun.sleep(0)
expect(steered).toEqual([])
app.mockInput.pressEnter()
@@ -1034,7 +1035,8 @@ test("direct footer rejects local commands submitted with the queue shortcut", a
try {
await app.renderOnce()
await app.mockInput.typeText("/settings ")
app.mockInput.pressEnter({ meta: true })
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressEnter()
await Bun.sleep(0)
expect(submitted).toEqual([])
expect(statuses).toContain("this prompt cannot be queued")
+1 -1
View File
@@ -22,7 +22,7 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("<leader>return")
})
test("preserves shared config while resolving independent Mini defaults", async () => {
+25 -82
View File
@@ -4127,10 +4127,10 @@
"summary": "Get session message"
}
},
"/api/session/{sessionID}/view": {
"post": {
"/api/session/{sessionID}/environment": {
"put": {
"tags": ["session"],
"operationId": "v2.session.view",
"operationId": "v2.session.environment",
"parameters": [
{
"name": "sessionID",
@@ -4182,8 +4182,28 @@
}
}
},
"description": "Mark the latest recorded idle transition as viewed.",
"summary": "View session"
"description": "Replace the process environment used by local shell commands for this session.",
"summary": "Set session environment",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"variables": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["variables"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/message": {
@@ -12194,12 +12214,6 @@
"updated": {
"type": "number"
},
"idle": {
"type": "number"
},
"viewed": {
"type": "number"
},
"archived": {
"type": "number"
}
@@ -14341,71 +14355,6 @@
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.viewed": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["session.viewed"]
},
"durable": {
"type": "object",
"properties": {
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer",
"allOf": [
{
"minimum": 0
}
]
},
"version": {
"type": "number",
"enum": [1]
}
},
"required": ["aggregateID", "seq", "version"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.deleted": {
"type": "object",
"properties": {
@@ -17426,9 +17375,6 @@
{
"$ref": "#/components/schemas/session.renamed"
},
{
"$ref": "#/components/schemas/session.viewed"
},
{
"$ref": "#/components/schemas/session.deleted"
},
@@ -22797,9 +22743,6 @@
{
"$ref": "#/components/schemas/session.renamed"
},
{
"$ref": "#/components/schemas/session.viewed"
},
{
"$ref": "#/components/schemas/session.usage.updated"
},
+25 -82
View File
@@ -4127,10 +4127,10 @@
"summary": "Get session message"
}
},
"/api/session/{sessionID}/view": {
"post": {
"/api/session/{sessionID}/environment": {
"put": {
"tags": ["session"],
"operationId": "v2.session.view",
"operationId": "v2.session.environment",
"parameters": [
{
"name": "sessionID",
@@ -4182,8 +4182,28 @@
}
}
},
"description": "Mark the latest recorded idle transition as viewed.",
"summary": "View session"
"description": "Replace the process environment used by local shell commands for this session.",
"summary": "Set session environment",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"variables": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["variables"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/message": {
@@ -12194,12 +12214,6 @@
"updated": {
"type": "number"
},
"idle": {
"type": "number"
},
"viewed": {
"type": "number"
},
"archived": {
"type": "number"
}
@@ -14341,71 +14355,6 @@
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.viewed": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["session.viewed"]
},
"durable": {
"type": "object",
"properties": {
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer",
"allOf": [
{
"minimum": 0
}
]
},
"version": {
"type": "number",
"enum": [1]
}
},
"required": ["aggregateID", "seq", "version"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.deleted": {
"type": "object",
"properties": {
@@ -17426,9 +17375,6 @@
{
"$ref": "#/components/schemas/session.renamed"
},
{
"$ref": "#/components/schemas/session.viewed"
},
{
"$ref": "#/components/schemas/session.deleted"
},
@@ -22797,9 +22743,6 @@
{
"$ref": "#/components/schemas/session.renamed"
},
{
"$ref": "#/components/schemas/session.viewed"
},
{
"$ref": "#/components/schemas/session.usage.updated"
},