Compare commits

..

6 Commits

Author SHA1 Message Date
exe.dev user bb3e9fb1a1 chore(core): separate source and test configs 2026-08-06 21:13:17 +00:00
exe.dev user bc89008514 chore: limit incremental typechecking to core 2026-08-06 20:46:32 +00:00
exe.dev user 6bcec1b159 chore(core): enable composite source checks 2026-08-06 20:38:26 +00:00
exe.dev user b3ec867da3 perf(core): isolate instruction typecheck 2026-08-06 19:26:43 +00:00
exe.dev user 6efb07e805 perf: enable incremental typechecking 2026-08-06 18:22:58 +00:00
exe.dev user 4980355300 perf(core): reduce query type variance work 2026-08-06 17:59:33 +00:00
86 changed files with 749 additions and 529 deletions
@@ -10,6 +10,7 @@ const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled",
} as const
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed") return s.error
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
}
const enabled = () => status() === "connected"
return (
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth")
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
}
export function hasNonBlockingServiceIssue(input: {
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
@@ -34,6 +34,7 @@ function icon(status: McpServer["status"]) {
case "needs_auth":
return "⚠"
case "failed":
case "needs_client_registration":
return "✗"
default:
return "○"
@@ -44,6 +45,8 @@ function describe(status: McpServer["status"]) {
switch (status.status) {
case "needs_auth":
return "needs authentication"
case "needs_client_registration":
return `needs client registration: ${status.error}`
case "failed":
return `failed: ${status.error}`
default:
@@ -259,6 +259,8 @@ export type McpStatusFailed = { status: "failed"; error: string }
export type McpStatusNeedsAuth = { status: "needs_auth" }
export type McpStatusNeedsClientRegistration = { status: "needs_client_registration"; error: string }
export type McpResource = { server: string; name: string; uri: string; description?: string; mimeType?: string }
export type McpResourceTemplate = {
@@ -1259,7 +1261,13 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type McpServer = {
name: string
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
status:
| McpStatusConnected
| McpStatusPending
| McpStatusDisabled
| McpStatusFailed
| McpStatusNeedsAuth
| McpStatusNeedsClientRegistration
integrationID?: string
}
+1 -1
View File
@@ -11,7 +11,7 @@
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo -b && tsgo --noEmit -p tsconfig.tests.json"
},
"bin": {
"opencode": "./bin/opencode"
+10 -6
View File
@@ -132,14 +132,16 @@ function renderMigration(name: string, sql: string) {
return `import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: ${JSON.stringify(name)},
up(tx) {
return Effect.gen(function* () {
${renderStatements(sql)}
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
`
}
@@ -147,13 +149,15 @@ function renderSchema(sql: string) {
return `import { Effect } from "effect"
import type { DatabaseMigration } from "./migration"
export default {
const schema: Omit<DatabaseMigration.Migration, "id"> = {
up(tx) {
return Effect.gen(function* () {
${renderStatements(sql)}
})
},
} satisfies Omit<DatabaseMigration.Migration, "id">
}
export default schema
`
}
@@ -191,10 +195,10 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
export const migrations = (
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
).map((module) => module.default)
`
}
+1 -1
View File
@@ -233,13 +233,13 @@ export const layer = (options?: Options) => Layer.effect(
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...(supplementary[0] ?? []),
...explicit,
...direct,
...supplementary.slice(1).flat(),
...(yield* loadWellknown().pipe(Effect.orDie)),
...content,
]
})
+2 -2
View File
@@ -1,6 +1,6 @@
import type { DatabaseMigration } from "./migration"
export const migrations = (
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
@@ -43,4 +43,4 @@ export const migrations = (
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
).map((module) => module.default)
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260127222353_familiar_lady_ursula",
up(tx) {
return Effect.gen(function* () {
@@ -104,4 +104,6 @@ export default {
yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,11 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260211171708_add_project_commands",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`project\` ADD \`commands\` text;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260213144116_wakeful_the_professor",
up(tx) {
return Effect.gen(function* () {
@@ -20,4 +20,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260225215848_workspace",
up(tx) {
return Effect.gen(function* () {
@@ -16,4 +16,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260227213759_add_session_workspace_id",
up(tx) {
return Effect.gen(function* () {
@@ -9,4 +9,6 @@ export default {
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260228203230_blue_harpoon",
up(tx) {
return Effect.gen(function* () {
@@ -27,4 +27,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260303231226_add_workspace_fields",
up(tx) {
return Effect.gen(function* () {
@@ -12,4 +12,6 @@ export default {
yield* tx.run(`ALTER TABLE \`workspace\` DROP COLUMN \`config\`;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260309230000_move_org_to_state",
up(tx) {
return Effect.gen(function* () {
@@ -12,4 +12,6 @@ export default {
yield* tx.run(`ALTER TABLE \`account\` DROP COLUMN \`selected_org_id\`;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260312043431_session_message_cursor",
up(tx) {
return Effect.gen(function* () {
@@ -13,4 +13,6 @@ export default {
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260323234822_events",
up(tx) {
return Effect.gen(function* () {
@@ -23,4 +23,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260410174513_workspace-name",
up(tx) {
return Effect.gen(function* () {
@@ -26,4 +26,6 @@ export default {
yield* tx.run(`PRAGMA foreign_keys=ON;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260413175956_chief_energizer",
up(tx) {
return Effect.gen(function* () {
@@ -21,4 +21,6 @@ export default {
yield* tx.run(`CREATE INDEX \`session_entry_time_created_idx\` ON \`session_entry\` (\`time_created\`);`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260423070820_add_icon_url_override",
up(tx) {
return Effect.gen(function* () {
@@ -11,4 +11,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260427172553_slow_nightmare",
up(tx) {
return Effect.gen(function* () {
@@ -27,4 +27,6 @@ export default {
yield* tx.run(`DROP TABLE \`session_entry\`;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,11 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260428004200_add_session_path",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`path\` text;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260501142318_next_venus",
up(tx) {
return Effect.gen(function* () {
@@ -9,4 +9,6 @@ export default {
yield* tx.run(`ALTER TABLE \`session\` ADD \`model\` text;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,11 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260504145000_add_sync_owner",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`event_sequence\` ADD \`owner_id\` text;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,11 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260507164347_add_workspace_time",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`time_used\` integer NOT NULL DEFAULT 0;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260510033149_session_usage",
up(tx) {
return Effect.gen(function* () {
@@ -53,4 +53,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260511000411_data_migration_state",
up(tx) {
return Effect.gen(function* () {
@@ -13,4 +13,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260511173437_session-metadata",
up(tx) {
return Effect.gen(function* () {
@@ -13,4 +13,6 @@ export default {
yield* tx.run(`ALTER TABLE \`session\` ADD \`metadata\` text;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260601010001_normalize_storage_paths",
up(tx) {
return Effect.gen(function* () {
@@ -19,4 +19,6 @@ export default {
)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,11 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260601202201_amazing_prowler",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP TABLE \`permission\`;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260602002951_lowly_union_jack",
up(tx) {
return Effect.gen(function* () {
@@ -21,4 +21,6 @@ export default {
)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260602182828_add_project_directories",
up(tx) {
return Effect.gen(function* () {
@@ -17,4 +17,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260603001617_session_message_projection_indexes",
up(tx) {
return Effect.gen(function* () {
@@ -16,4 +16,6 @@ export default {
)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260603040000_session_message_projection_order",
up(tx) {
return Effect.gen(function* () {
@@ -16,4 +16,6 @@ export default {
)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260603141458_session_input_inbox",
up(tx) {
return Effect.gen(function* () {
@@ -22,4 +22,6 @@ export default {
)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260603160727_jittery_ezekiel_stane",
up(tx) {
return Effect.gen(function* () {
@@ -17,4 +17,6 @@ export default {
)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260604172448_event_sourced_session_input",
up(tx) {
return Effect.gen(function* () {
@@ -44,4 +44,6 @@ export default {
)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260605003541_add_session_context_snapshot",
up(tx) {
return Effect.gen(function* () {
@@ -18,4 +18,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,11 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260605042240_add_context_epoch_agent",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`agent\` text DEFAULT 'build' NOT NULL;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260611035744_credential",
up(tx) {
return Effect.gen(function* () {
@@ -22,4 +22,6 @@ export default {
)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260611192811_lush_chimera",
up(tx) {
return Effect.gen(function* () {
@@ -22,4 +22,6 @@ export default {
`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260612174303_project_dir_strategy",
up(tx) {
return Effect.gen(function* () {
@@ -26,4 +26,6 @@ export default {
yield* tx.run(`PRAGMA foreign_keys=ON;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
@@ -10,4 +10,6 @@ export default {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260622170816_reset_v2_session_state",
up(tx) {
return Effect.gen(function* () {
@@ -12,4 +12,6 @@ export default {
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260622202450_simplify_session_input",
up(tx) {
return Effect.gen(function* () {
@@ -14,4 +14,6 @@ export default {
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260804233008_loose_psylocke",
up(tx) {
return Effect.gen(function* () {
@@ -135,4 +135,6 @@ export default {
yield* tx.run(`DROP TABLE \`session_input\`;`)
})
},
} satisfies DatabaseMigration.Migration
}
export default migration
@@ -30,12 +30,14 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeValue = Schema.decodeUnknownOption(LegacyValue)
const wellKnownSourcesKey = "wellknown:sources"
export default {
const migration: DatabaseMigration.Migration = {
id: "20260805200742_import_legacy_credentials",
up(tx) {
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
},
} satisfies DatabaseMigration.Migration
}
export default migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () {
+4 -2
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "./migration"
export default {
const schema: Omit<DatabaseMigration.Migration, "id"> = {
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
@@ -248,4 +248,6 @@ export default {
)
})
},
} satisfies Omit<DatabaseMigration.Migration, "id">
}
export default schema
+6 -6
View File
@@ -82,10 +82,10 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
}
}
export const empty: Instructions = []
export const empty: ReadonlyArray<Source> = []
/** Closes a typed definition into one `Source`, so differently typed sources compose. */
export function make<A>(source: Source.Definition<A>): Instructions {
export function make<A>(source: Source.Definition<A>): ReadonlyArray<Source> {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
@@ -121,7 +121,7 @@ export function make<A>(source: Source.Definition<A>): Instructions {
]
}
export function combine(values: ReadonlyArray<Instructions>): Instructions {
export function combine(values: ReadonlyArray<ReadonlyArray<Source>>): ReadonlyArray<Source> {
const sources = values.flat()
const keys = new Set<Key>()
for (const source of sources) {
@@ -131,7 +131,7 @@ export function combine(values: ReadonlyArray<Instructions>): Instructions {
return sources
}
export function read(value: Instructions): Effect.Effect<ReadResult> {
export function read(value: ReadonlyArray<Source>): Effect.Effect<ReadResult> {
return Effect.forEach(
value,
(source) => source.read.pipe(Effect.map((observed) => ({ key: source.key, value: observed }))),
@@ -158,7 +158,7 @@ export function diff(observed: ReadResult, previous?: Values): Effect.Effect<Adm
return Effect.succeed({ delta, blobs })
}
export function renderInitial(value: Instructions, values: Readonly<Record<string, Schema.Json>>) {
export function renderInitial(value: ReadonlyArray<Source>, values: Readonly<Record<string, Schema.Json>>) {
return render(
value.flatMap((source) => {
if (!Object.hasOwn(values, source.key)) return []
@@ -169,7 +169,7 @@ export function renderInitial(value: Instructions, values: Readonly<Record<strin
}
export function renderUpdate(
value: Instructions,
value: ReadonlyArray<Source>,
previous: Readonly<Record<string, Schema.Json>>,
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
) {
+58 -2
View File
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
}),
)
function sessionHook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
): Promise<Registration>
function sessionHook(
...registration: {
[Name in keyof SessionHooks]: [
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
]
}[keyof SessionHooks]
) {
if (registration[0] !== "http")
return register(
host.session.hook(registration[0], (event) =>
Effect.promise(() => Promise.resolve(registration[1](event))),
),
)
return register(
host.session.hook("http", (event) => {
const middlewares: SessionHttpMiddleware[] = []
const output: SessionHttp = {
...event,
use: (item) => {
middlewares.push(item)
},
}
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
Effect.flatMap(() =>
Effect.forEach(
middlewares,
(item) =>
event.use((input, next) =>
Effect.tryPromise({
try: (signal) => {
const inputSignal = AbortSignal.any([signal, input.signal])
return Promise.resolve(
item(new Request(input, { signal: inputSignal }), (request) => {
const requestSignal = AbortSignal.any([signal, request.signal])
return Effect.runPromiseWith(
context,
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
}),
)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}),
),
{ discard: true },
),
),
)
}),
)
}
const context2: Context = {
app: host.app,
options: host.options,
@@ -265,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
hook: sessionHook,
create: (input) =>
run(
host.session.create(
+9 -9
View File
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
}
draft.cost = []
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
})
}
})
yield* ctx.session.hook("http.request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.request.url)
evt.request.headers.set("originator", "opencode")
evt.request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
yield* ctx.session.hook("http", (evt) =>
evt.use((request, next) => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
const url = new URL(request.url)
request.headers.set("originator", "opencode")
request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return next(request)
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
}),
)
+1 -1
View File
@@ -20,7 +20,7 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
+35 -21
View File
@@ -2,6 +2,7 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
@@ -229,31 +230,44 @@ export const layer = Layer.effect(
const options: StreamOptions = {
http: (request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
let latest = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const middlewares: SessionHttpMiddleware[] = []
const web = yield* HttpClientRequest.toWeb(request)
yield* hooks.trigger("session", "http", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: yield* HttpClientRequest.toWeb(request),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
const send = (input: Request) =>
Effect.gen(function* () {
let sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
latest = sent
const response = yield* handler(sent)
const body = [204, 205, 304].includes(response.status)
? null
: yield* Stream.toReadableStreamEffect(response.stream)
const output = new Response(body, { status: response.status, headers: response.headers })
origins.set(output, sent)
return output
})
const dispatch = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
send,
)
const response = yield* dispatch(web)
const origin = origins.get(response) ?? latest
return HttpClientResponse.fromWeb(origin, response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
}
if (promptCacheSnapshots) {
+48 -44
View File
@@ -13,54 +13,13 @@ export abstract class NamedError extends Error {
static create<Name extends string, Fields extends Schema.Struct.Fields>(
name: Name,
fields: Fields,
): ReturnType<typeof NamedError.createSchemaClass<Name, Schema.Struct<Fields>>>
): ReturnType<typeof createSchemaClass<Name, Schema.Struct<Fields>>>
static create<Name extends string, DataSchema extends Schema.Top>(
name: Name,
data: DataSchema,
): ReturnType<typeof NamedError.createSchemaClass<Name, DataSchema>>
): ReturnType<typeof createSchemaClass<Name, DataSchema>>
static create<Name extends string>(name: Name, data: Schema.Top | Schema.Struct.Fields) {
return NamedError.createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data))
}
private static createSchemaClass<Name extends string, DataSchema extends Schema.Top>(name: Name, data: DataSchema) {
const schema = Schema.Struct({
name: Schema.Literal(name),
data,
}).annotate({ identifier: name })
type Data = Schema.Schema.Type<DataSchema>
const result = class extends NamedError {
public static readonly Schema = schema
public static readonly EffectSchema = schema
public static readonly tag = name
public override readonly name = name
constructor(
public readonly data: Data,
options?: ErrorOptions,
) {
super(name, options)
this.name = name
}
static isInstance(input: unknown): input is InstanceType<typeof result> {
return NamedError.hasName(input, name)
}
schema() {
return schema
}
toObject() {
return {
name: name,
data: this.data,
}
}
}
Object.defineProperty(result, "name", { value: name })
return result
return createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data), this)
}
public static readonly Unknown = NamedError.create("UnknownError", {
@@ -68,3 +27,48 @@ export abstract class NamedError extends Error {
ref: Schema.optional(Schema.String),
})
}
function createSchemaClass<Name extends string, DataSchema extends Schema.Top>(
name: Name,
data: DataSchema,
base: typeof NamedError = NamedError,
) {
const schema = Schema.Struct({
name: Schema.Literal(name),
data,
}).annotate({ identifier: name })
type Data = Schema.Schema.Type<DataSchema>
const result = class extends base {
public static readonly Schema = schema
public static readonly EffectSchema = schema
public static readonly tag = name
public override readonly name = name
constructor(
public readonly data: Data,
options?: ErrorOptions,
) {
super(name, options)
this.name = name
}
static isInstance(input: unknown): input is InstanceType<typeof result> {
return base.hasName(input, name)
}
schema() {
return schema
}
toObject() {
return {
name: name,
data: this.data,
}
}
}
Object.defineProperty(result, "name", { value: name })
return result
}
@@ -151,9 +151,6 @@ describe("ModelResolver", () => {
http: { body: { custom_extension: { enabled: true } } },
},
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.max_output_tokens).toBeUndefined()
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
}),
)
+82 -25
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
import { host as testHost } from "./host"
@@ -223,45 +223,102 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP request and response hooks", () =>
it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
const bodies: string[] = []
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
request.headers.set("x-hook", "promise")
await next(request)
const response = await next(request)
return new Response(`${await response.text()}-response`)
})
})
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
const response = await next(request)
return new Response(`${await response.text()}-outer`)
})
})
},
}),
).effect(host)
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) =>
Effect.promise(() => input.text()).pipe(
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
),
)
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
expect(bodies).toEqual(["payload", "payload"])
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
}),
)
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
id: "promise-session-http-interrupt",
setup: async (ctx) => {
await ctx.session.hook("http.request", (event) => {
event.request = new Request("https://provider.test/changed", event.request)
event.request.headers.set("x-hook", "promise")
})
await ctx.session.hook("http.response", async (event) => {
event.response = new Response(`${await event.response.text()}-response`, {
status: event.response.status,
})
await ctx.session.hook("http", (event) => {
event.use((request, next) => next(request))
})
},
}),
).effect(host)
const context = {
sessionID: Session.ID.make("ses_promise_session_http"),
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
const request = yield* hooks.trigger("session", "http.request", {
...context,
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
})
const response = yield* hooks.trigger("session", "http.response", {
...context,
request: request.request,
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
})
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
() =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
)
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(request.request.url).toBe("https://provider.test/changed")
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
}),
)
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider"
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -30,13 +31,26 @@ function required<T>(value: T | undefined): T {
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
request: new Request(url, { method: "POST", body: "{}" }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
})
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
)
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
})
describe("OpenAIPlugin", () => {
@@ -126,7 +140,7 @@ describe("OpenAIPlugin", () => {
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -135,14 +149,14 @@ describe("OpenAIPlugin", () => {
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 400_000,
context: 272_000,
input: 272_000,
output: 64_000,
})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
@@ -255,7 +255,6 @@ describe("SessionRunnerLLM recorded", () => {
describe("SessionModelRequest HTTP bridge", () => {
const bodies: Uint8Array[] = []
const methods: string[] = []
const headers: Array<string | undefined> = []
const response = [
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
@@ -269,7 +268,6 @@ describe("SessionModelRequest HTTP bridge", () => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
methods.push(request.method)
bodies.push(request.body.body.slice())
headers.push(request.headers["x-hook"])
return HttpClientResponse.fromWeb(
request,
new Response(response, { headers: { "content-type": "text/event-stream" } }),
@@ -277,16 +275,14 @@ describe("SessionModelRequest HTTP bridge", () => {
}),
),
)
const httpIt = testEffect(
const retryIt = testEffect(
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
)
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
Effect.gen(function* () {
bodies.length = 0
methods.length = 0
headers.length = 0
const seen: string[] = []
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
@@ -301,20 +297,13 @@ describe("SessionModelRequest HTTP bridge", () => {
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* pluginHost.session.hook("http.request", (event) =>
Effect.sync(() => {
seen.push("request")
event.request.headers.set("x-hook", "effect")
}),
)
yield* pluginHost.session.hook("http.response", (event) =>
Effect.gen(function* () {
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
event.response = new Response(
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
event.response,
)
}),
yield* pluginHost.session.hook("http", (event) =>
event.use((request, next) =>
Effect.gen(function* () {
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
return yield* next(request)
}),
),
)
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
@@ -342,15 +331,10 @@ describe("SessionModelRequest HTTP bridge", () => {
yield* session.resume(retrySessionID)
expect(methods).toEqual(["POST"])
expect(headers).toEqual(["effect"])
expect(seen).toEqual(["request", "response:200:effect"])
expect(bodies).toHaveLength(1)
expect(methods).toEqual(["POST", "POST"])
expect(bodies).toHaveLength(2)
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
type: "assistant",
content: [{ type: "text", text: "Hooked!" }],
})
expect(bodies[1]).toEqual(bodies[0])
}),
)
})
+11 -2
View File
@@ -2,6 +2,15 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false
}
"composite": true,
"declaration": true,
"emitDeclarationOnly": true,
"incremental": true,
"noEmit": false,
"noUncheckedIndexedAccess": false,
"outDir": "node_modules/.ts-dist/source",
"rootDir": "src",
"tsBuildInfoFile": "node_modules/.ts-dist/source.tsbuildinfo"
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"incremental": true,
"noUncheckedIndexedAccess": false,
"tsBuildInfoFile": "node_modules/.ts-dist/tests.tsbuildinfo"
},
"include": ["drizzle.config.ts", "script", "test"],
"references": [{ "path": "./tsconfig.json" }]
}
@@ -110,12 +110,12 @@ export type SQLiteEffectDelete<
export type AnySQLiteEffectDelete = SQLiteEffectDeleteBase<any, any, any, any, any, any>
export interface SQLiteEffectDeleteBase<
TTable extends SQLiteTable,
TRunResult,
TReturning extends Record<string, unknown> | undefined = undefined,
TDynamic extends boolean = false,
out TTable extends SQLiteTable,
out TRunResult,
out TReturning extends Record<string, unknown> | undefined = undefined,
out TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
SQLWrapper,
Effect.Effect<
@@ -137,12 +137,12 @@ export interface SQLiteEffectDeleteBase<
}
export class SQLiteEffectDeleteBase<
TTable extends SQLiteTable,
TRunResult,
TReturning extends Record<string, unknown> | undefined = undefined,
TDynamic extends boolean = false,
out TTable extends SQLiteTable,
out TRunResult,
out TReturning extends Record<string, unknown> | undefined = undefined,
out TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
@@ -126,9 +126,9 @@ export type SQLiteEffectInsert<
export type AnySQLiteEffectInsert = SQLiteEffectInsertBase<any, any, any, any, any, any>
export class SQLiteEffectInsertBuilder<
TTable extends SQLiteTable,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
in out TTable extends SQLiteTable,
out TRunResult,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectInsertBuilder"
@@ -194,12 +194,12 @@ export class SQLiteEffectInsertBuilder<
}
export interface SQLiteEffectInsertBase<
TTable extends SQLiteTable,
TRunResult,
TReturning = undefined,
TDynamic extends boolean = false,
in out TTable extends SQLiteTable,
out TRunResult,
out TReturning = undefined,
out TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLWrapper,
RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
Effect.Effect<
@@ -221,12 +221,12 @@ export interface SQLiteEffectInsertBase<
}
export class SQLiteEffectInsertBase<
TTable extends SQLiteTable,
TRunResult,
TReturning = undefined,
TDynamic extends boolean = false,
in out TTable extends SQLiteTable,
out TRunResult,
out TReturning = undefined,
out TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
@@ -19,9 +19,9 @@ import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export class SQLiteEffectRelationalQueryBuilder<
TSchema extends TablesRelationalConfig,
out TSchema extends TablesRelationalConfig,
TFields extends TableRelationalConfig,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectRelationalQueryBuilderV2"
@@ -152,18 +152,18 @@ export interface SQLiteEffectSelectHKT<TEffectHKT extends QueryEffectHKTBase = Q
}
export interface SQLiteEffectSelectBase<
TTableName extends string | undefined,
TRunResult,
TSelection extends ColumnsSelection,
TSelectMode extends SelectMode = "single",
TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
out TTableName extends string | undefined,
out TRunResult,
out TSelection extends ColumnsSelection,
out TSelectMode extends SelectMode = "single",
out TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
? Record<TTableName, "not-null">
: {},
TDynamic extends boolean = false,
out TDynamic extends boolean = false,
TExcludedMethods extends string = never,
TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
@@ -180,18 +180,18 @@ export interface SQLiteEffectSelectBase<
Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]> {}
export class SQLiteEffectSelectBase<
TTableName extends string | undefined,
TRunResult,
TSelection extends ColumnsSelection,
TSelectMode extends SelectMode = "single",
TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
out TTableName extends string | undefined,
out TRunResult,
out TSelection extends ColumnsSelection,
out TSelectMode extends SelectMode = "single",
out TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
? Record<TTableName, "not-null">
: {},
TDynamic extends boolean = false,
out TDynamic extends boolean = false,
TExcludedMethods extends string = never,
TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
@@ -158,9 +158,9 @@ export type SQLiteEffectUpdateJoinFn<T extends AnySQLiteEffectUpdate> = <
) => T
export class SQLiteEffectUpdateBuilder<
TTable extends SQLiteTable,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
in out TTable extends SQLiteTable,
out TRunResult,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectUpdateBuilder"
@@ -193,13 +193,13 @@ export class SQLiteEffectUpdateBuilder<
}
export interface SQLiteEffectUpdateBase<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning = undefined,
TDynamic extends boolean = false,
out TTable extends SQLiteTable = SQLiteTable,
out TRunResult = unknown,
out TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
out TReturning = undefined,
out TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLWrapper,
RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
Effect.Effect<
@@ -222,13 +222,13 @@ export interface SQLiteEffectUpdateBase<
}
export class SQLiteEffectUpdateBase<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning = undefined,
TDynamic extends boolean = false,
out TTable extends SQLiteTable = SQLiteTable,
out TRunResult = unknown,
out TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
out TReturning = undefined,
out TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
+10 -12
View File
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Effect, JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -15,25 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: Request
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
}
export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly request: Request
response: Response
}
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Effect.Effect<Response, Error>
export interface SessionHooks {
readonly context: SessionContext
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly http: SessionHttp
}
export type SessionDomain = Pick<
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Hooks, Transform } from "./registration.js"
interface ToolDraft {
export interface ToolDraft {
add<
Input extends Tool.ValueSchema<any>,
Output extends Tool.ValueSchema<any> | undefined,
+9 -11
View File
@@ -15,25 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: Request
readonly use: (middleware: SessionHttpMiddleware) => void
}
export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly request: Request
response: Response
}
export type SessionHttpHandler = (request: Request) => Promise<Response>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Promise<Response> | Response
export interface SessionHooks {
readonly context: SessionContext
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly http: SessionHttp
}
export type SessionDomain = Pick<
+22
View File
@@ -20687,6 +20687,25 @@
],
"additionalProperties": false
},
"Mcp.Status.NeedsClientRegistration": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"needs_client_registration"
]
},
"error": {
"type": "string"
}
},
"required": [
"status",
"error"
],
"additionalProperties": false
},
"Mcp.Server": {
"type": "object",
"properties": {
@@ -20709,6 +20728,9 @@
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
}
]
},
+5 -1
View File
@@ -68,9 +68,13 @@ const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.S
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
identifier: "Mcp.Status.NeedsAuth",
})
const NeedsClientRegistration = Schema.Struct({
status: Schema.Literal("needs_client_registration"),
error: Schema.String,
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
export type Status = typeof Status.Type
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe(
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
Schema.toTaggedUnion("status"),
)
+1 -1
View File
@@ -15,7 +15,7 @@ import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
return undefined
}
+2 -6
View File
@@ -8,21 +8,17 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected()
const providers = createMemo(
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)
@@ -14,6 +14,7 @@ export function DialogStatus() {
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
return (
@@ -45,6 +46,9 @@ export function DialogStatus() {
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
<Match when={item.status.status === "needs_client_registration" && item.status}>
{(val) => (val() as { error: string }).error}
</Match>
</Switch>
</span>
</text>
+44 -43
View File
@@ -327,6 +327,10 @@ export function Prompt(props: PromptProps) {
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID
})
@@ -939,25 +943,6 @@ export function Prompt(props: PromptProps) {
await slash.command.run(slash.input)
return true
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
const slashHead = parseSlashHead(inputText, /\s/)
const isSkill =
slashHead !== undefined &&
(data.location.skill.list(currentLocation.ref) ?? []).some(
(skill) => skill.slash === true && skill.id === slashHead.name,
)
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()
@@ -965,15 +950,6 @@ export function Prompt(props: PromptProps) {
void promptModelWarning()
return false
}
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
if (usesModel && !local.model.available(selectedModel)) {
toast.show({
title: "Model unavailable",
message: `${selectedModel.providerID}/${selectedModel.modelID} is not available in this session's location`,
variant: "warning",
})
return false
}
const variant = local.model.variant.current()
let sessionID = props.sessionID
@@ -1014,6 +990,17 @@ export function Prompt(props: PromptProps) {
session = created
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
@@ -1026,30 +1013,43 @@ export function Prompt(props: PromptProps) {
command: inputText,
})
setStore("mode", "normal")
} else if (slashHead && isCommand) {
} else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit()
const model = { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }
const cancelCommit = local.model.expectCommit(sessionID, model)
// Parse command from first line, preserve multi-line content in arguments
const firstLineEnd = inputText.indexOf("\n")
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
void client.api.session
.command({
sessionID,
command: slashHead.name,
arguments: slashHead.arguments,
command: command.slice(1),
arguments: args,
agent: agent.id,
model,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
files: store.prompt.files,
agents: store.prompt.agents,
})
.catch((error) => {
cancelCommit()
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
})
} else if (isSkill) {
} else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit()
void client.api.session.skill({
sessionID,
skill: slashHead!.name,
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
})
} else {
move.startSubmit()
@@ -1065,11 +1065,9 @@ export function Prompt(props: PromptProps) {
session.model.id !== selectedModel.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default")
) {
const model = { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }
const cancelCommit = local.model.expectCommit(sessionID, model)
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
cancelCommit()
throw error
await client.api.session.switchModel({
sessionID,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
})
}
if (session?.revert) {
@@ -1322,7 +1320,10 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})()
if (!value) return undefined
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
const width =
dimensions().width < 44
? dimensions().width - 5
: Math.min(75, dimensions().width - 4) - 5
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
})
const locationLabel = createMemo(() => {
+37 -132
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper"
import { batch, createMemo, onCleanup } from "solid-js"
import { batch, createMemo } from "solid-js"
import { useEvent } from "./event"
import path from "path"
import { useTuiPaths } from "./runtime"
@@ -22,7 +22,6 @@ import { useToast } from "../ui/toast"
import { useRoute } from "./route"
import { useData } from "./data"
import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
@@ -58,29 +57,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs()
const event = useEvent()
const permission = usePermission()
const location = useLocation()
const models = () => data.location.model.list(location.ref)
const providers = () => data.location.provider.list(location.ref)
function isModelValid(model: ModelPreferenceModel) {
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
return !!data.location.model
.list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
}
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (model && isModelValid(model)) return model
if (!model) continue
if (isModelValid(model)) return model
}
}
function createAgent() {
const agents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
@@ -132,26 +128,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = createAgent()
function createModel() {
type Selection = ModelPreferenceModel & { variant?: string }
const [modelStore, setModelStore] = createStore<
ModelPreference & {
ready: boolean
defaults: Record<string, ModelPreferenceModel | undefined>
drafts: Record<string, Selection | undefined>
model: Record<string, ModelPreferenceModel>
}
>({
ready: false,
defaults: {},
drafts: {},
model: {},
recent: [],
favorite: [],
variant: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const pendingCommits = new Map<string, string>()
const commitKey = (value: ModelPreferenceModel & { variant?: string }) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const state = {
pending: false,
}
@@ -201,7 +191,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const model = models()?.[0]
const model = data.location.model.list()?.[0]
if (!model) return undefined
return {
providerID: model.providerID,
@@ -210,109 +200,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const currentModel = createMemo(() => {
const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
if (sessionID) return modelStore.drafts[sessionID] ?? durableSelection(sessionID)
const a = agent.current()
const fallback = getFirstValidModel(
() => a && modelStore.defaults[agentModelKey(a.id)],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
return (
getFirstValidModel(
() => a && modelStore.model[a.id],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
) ?? undefined
)
return fallback
})
function agentModelKey(agentID: string) {
const ref = location.ref ?? data.location.default()
return `agent:${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function durableSelection(sessionID: string): Selection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
return {
providerID: model.providerID,
modelID: model.id,
variant: normalizeModelVariant(model.variant),
}
}
function setDraft(sessionID: string, selection: Selection) {
const durable = durableSelection(sessionID)
setModelStore(
"drafts",
sessionID,
durable && commitKey(durable) === commitKey(selection) ? undefined : selection,
)
}
function select(model: ModelPreferenceModel) {
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = modelStore.drafts[sessionID] ?? durableSelection(sessionID)
const preferred = normalizeModelVariant(
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: modelStore.variant[modelPreferenceKey(model)],
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
if (!current) return false
setModelStore("defaults", agentModelKey(current.id), model)
return true
}
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingCommits.get(evt.data.sessionID)
if (!expected) return
pendingCommits.delete(evt.data.sessionID)
const committed = commitKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
const draft = modelStore.drafts[evt.data.sessionID]
if (draft && commitKey(draft) === committed) setModelStore("drafts", evt.data.sessionID, undefined)
}),
)
onCleanup(
event.on("session.deleted", (evt) => {
pendingCommits.delete(evt.data.sessionID)
setModelStore("drafts", evt.data.sessionID, undefined)
}),
)
return {
current: currentModel,
available(model = currentModel()) {
return model ? isModelValid(model) : false
},
expectCommit(
sessionID: string,
value: {
providerID: string
id: string
variant?: string
},
) {
const committed = commitKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
pendingCommits.set(sessionID, committed)
return () => {
if (pendingCommits.get(sessionID) === committed) pendingCommits.delete(sessionID)
}
},
get ready() {
return modelStore.ready
},
get catalogReady() {
return models() !== undefined
},
recent() {
return modelStore.recent
},
@@ -328,25 +230,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false,
}
}
const provider = providers()?.find((item) => item.id === value.providerID)
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
const info = data.location.model
.list()
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return {
provider: provider?.name ?? value.providerID,
model: info?.name ?? `${value.modelID} (unavailable)`,
model: info?.name ?? value.modelID,
reasoning: (info?.variants?.length ?? 0) !== 0,
}
}),
cycle(direction: 1 | -1) {
const current = currentModel()
if (!current) return
const recent = recentModels(current, modelStore.recent).filter(isModelValid)
const recent = modelStore.recent
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
if (index === -1) return
let next = index + direction
if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0
const val = recent[next]
if (!val) return
select({ ...val })
const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...val })
},
cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
@@ -372,14 +279,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const next = favorites[index]
if (!next) return
if (!select({ ...next })) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...next })
setModelStore("recent", recentModels(next, modelStore.recent))
save()
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) return
if (!select(model)) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, model)
if (options?.recent) {
setModelStore("recent", recentModels(model, modelStore.recent))
save()
@@ -406,10 +317,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
selected() {
const m = currentModel()
if (!m) return undefined
if (route.data.type === "session") {
const selection = modelStore.drafts[route.data.sessionID] ?? durableSelection(route.data.sessionID)
if (selection?.providerID === m.providerID && selection.modelID === m.modelID) return selection.variant
}
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
},
current() {
@@ -420,16 +327,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
list() {
const m = currentModel()
if (!m) return []
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
const info = data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants?.map((variant) => variant.id) ?? []
},
set(value: string | undefined) {
const m = currentModel()
if (!m) return
if (route.data.type === "session") {
setDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
return
}
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
save()
},
@@ -8,7 +8,13 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
const bad = createMemo(
() => list().filter((item) => item.status.status === "failed" || item.status.status === "needs_auth").length,
() =>
list().filter(
(item) =>
item.status.status === "failed" ||
item.status.status === "needs_auth" ||
item.status.status === "needs_client_registration",
).length,
)
const dot = (status: string) => {
@@ -16,6 +22,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
if (status === "failed") return theme.text.feedback.error.default
if (status === "disabled") return theme.text.subdued
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
@@ -58,6 +65,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
</Match>
<Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
</Switch>
</span>
</text>
+1 -1
View File
@@ -361,7 +361,7 @@ export function Session() {
createEffect(() => {
const current = prompt()
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
if (sent || !current || !synced() || !local.model.ready) return
if (!local.agent.current() || !local.model.current()) return
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
sent = true
@@ -83,7 +83,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"auto": true,
"prune": false,
"keep": {
"tokens": 15000
"tokens": 8000
},
"buffer": 20000
}
@@ -94,7 +94,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
+8 -16
View File
@@ -246,27 +246,19 @@ Runtime hooks intercept live operations:
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch |
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
HTTP hooks can modify requests and responses. They apply to native models; AI
SDK models do not currently pass through these hooks. Request and response
bodies are one-shot streams. Use `clone()` when you intentionally need a
separate reader, but be aware that its slower branch may buffer data. To inspect
or modify chunks while preserving streaming, replace the body with one piped
through a `TransformStream`.
HTTP hooks can modify requests, inspect responses, retry, or return a
response without calling the provider. It applies to native models; AI SDK
models do not currently pass through this hook.
```ts
await ctx.session.hook("http.request", (event) => {
event.request.headers.set("x-session-id", event.sessionID)
})
await ctx.session.hook("http.response", (event) => {
event.response = new Response(event.response.body, {
status: event.response.status,
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
await ctx.session.hook("http", (event) => {
event.use((request, next) => {
request.headers.set("x-session-id", event.sessionID)
return next(request)
})
})
```
+1 -1
View File
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
"compaction": {
"auto": true,
"keep": {
"tokens": 15000
"tokens": 8000
},
"buffer": 20000
}
+22
View File
@@ -20687,6 +20687,25 @@
],
"additionalProperties": false
},
"Mcp.Status.NeedsClientRegistration": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"needs_client_registration"
]
},
"error": {
"type": "string"
}
},
"required": [
"status",
"error"
],
"additionalProperties": false
},
"Mcp.Server": {
"type": "object",
"properties": {
@@ -20709,6 +20728,9 @@
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
}
]
},
+22
View File
@@ -20687,6 +20687,25 @@
],
"additionalProperties": false
},
"Mcp.Status.NeedsClientRegistration": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"needs_client_registration"
]
},
"error": {
"type": "string"
}
},
"required": [
"status",
"error"
],
"additionalProperties": false
},
"Mcp.Server": {
"type": "object",
"properties": {
@@ -20709,6 +20728,9 @@
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
}
]
},