resolve merge conflicts

This commit is contained in:
marius-kilocode
2026-07-22 15:00:34 +02:00
182 changed files with 5561 additions and 1892 deletions
+1 -1
View File
@@ -1 +1 @@
v1.17.5
v1.17.9
+5
View File
@@ -52,6 +52,9 @@ _Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `KILO_TERMINAL`.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `KILO_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases.
minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours)
minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test]
root = "./do-not-run-tests-from-root"
+13 -7
View File
@@ -7,7 +7,6 @@
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:local": "bun run packages/opencode/script/dev-local.ts",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
@@ -19,7 +18,8 @@
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1",
"extension": "bun --cwd packages/kilo-vscode script/launch.ts",
"dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup"
"dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup",
"dev:local": "bun run packages/opencode/script/dev-local.ts"
},
"workspaces": {
"packages": [
@@ -45,7 +45,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.22",
"@pierre/diffs": "1.2.10",
"opentui-spinner": "0.0.7",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
@@ -70,7 +70,7 @@
"@typescript/native-preview": "7.0.0-dev.20260316.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"shiki": "3.20.0",
"shiki": "4.2.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.49.1",
@@ -85,7 +85,9 @@
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"sst": "4.13.1"
"sst": "4.13.1",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0"
}
},
"devDependencies": {
@@ -157,11 +159,15 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
+2 -2
View File
@@ -10,8 +10,8 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"test": "bun test --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"bin": {
"opencode": "./bin/opencode"
+41 -55
View File
@@ -10,8 +10,7 @@ import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema"
import { Integration } from "./integration"
export type ProviderRecord = {
provider: ProviderV2.Info
@@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
ModelUpdated: EventV2.define({
type: "catalog.model.updated",
schema: {
model: ModelV2.Info,
},
}),
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
}
type Data = {
@@ -96,31 +90,20 @@ export const layer = Layer.effect(
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const scope = yield* Scope.Scope
const project = (provider: ProviderV2.Info, active: Map<IntegrationSchema.ID, Credential.Stored>) => {
const credential = active.get(IntegrationSchema.ID.make(provider.id))
if (!credential) return provider
const body = { ...provider.request.body }
if (credential.value.type === "key") {
body.apiKey = credential.value.key
Object.assign(body, credential.value.metadata ?? {})
}
// kilocode_change start - preserve Kilo organization routing from migrated OAuth credentials
if (credential.value.type === "oauth") {
body.apiKey = credential.value.access
if (credential.value.metadata?.accountID) body.kilocodeOrganizationId = credential.value.metadata.accountID
}
// kilocode_change end
return new ProviderV2.Info({
...provider,
enabled: { via: "credential", credentialID: credential.id },
request: { ...provider.request, body },
})
// kilocode_change start - upstream moved credential projection into SessionRunnerModel; the Kilo OAuth
// accountID -> kilocodeOrganizationId mapping is ported there, while this helper only filters availability.
// kilocode_change end
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true
return !integration
}
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const api =
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
? { ...provider.api, id: model.api.id }
@@ -208,18 +191,16 @@ export const layer = Layer.effect(
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
if (!policy.hasStatements()) return
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
}
}
}
yield* events.publish(Event.Updated, {})
}),
})
const active = Effect.fn("CatalogV2.active")(function* () {
return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential]))
})
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter(
@@ -238,18 +219,23 @@ export const layer = Layer.effect(
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return project(record.provider, yield* active())
return record.provider
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
const credentials = yield* active()
return Array.fromIterable(state.get().providers.values()).map((record) =>
project(record.provider, credentials),
)
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
}),
available: Effect.fn("CatalogV2.provider.available")(function* () {
return (yield* result.provider.all()).filter((provider) => provider.enabled)
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) =>
available(
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
)
}),
},
@@ -258,33 +244,32 @@ export const layer = Layer.effect(
const record = yield* getRecord(providerID)
const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return resolve(model, project(record.provider, yield* active()))
return projectModel(model, record.provider)
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
const credentials = yield* active()
return pipe(
Array.fromIterable(state.get().providers.values()),
Array.flatMap((record) => {
const provider = project(record.provider, credentials)
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
available: Effect.fn("CatalogV2.model.available")(function* () {
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
return (yield* result.model.all()).filter(
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
)
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
}),
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (Option.isSome(provider) && provider.value.enabled !== false) {
if (
Option.isSome(provider) &&
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
}
@@ -300,11 +285,11 @@ export const layer = Layer.effect(
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>()
const provider = project(record.provider, yield* active())
const provider = record.provider
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
}
const candidates = pipe(
@@ -332,7 +317,7 @@ export const layer = Layer.effect(
return pipe(
items,
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
Array.map((item) => resolve(item.model, provider)),
Array.map((item) => projectModel(item.model, provider)),
Array.head,
)
}
@@ -353,6 +338,7 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer),
)
+25 -2
View File
@@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider"
import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin"
@@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const config = yield* Config.Service
const integrations = yield* Integration.Service
const transform = yield* catalog.transform()
const integrationTransform = yield* integrations.transform()
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
),
)
yield* integrationTransform((integrations) => {
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = Integration.ID.make(id)
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
}
}
})
yield* transform((catalog) => {
const configuredDefault = Config.latest(entries, "model")
@@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({
const providerID = ProviderV2.ID.make(id)
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.env !== undefined) provider.env = [...item.env]
provider.enabled = { via: "custom", data: {} }
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
+7
View File
@@ -102,6 +102,8 @@ export interface Interface {
readonly all: () => Effect.Effect<Stored[]>
/** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
/** Replaces any credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: IntegrationSchema.ID
@@ -350,6 +352,11 @@ export const layer = Layer.effect(
return credential ? [credential] : []
})
}),
get: Effect.fn("Credential.get")(function* (id) {
if (isolated) return find(id) // kilocode_change - injected workspace credentials are process-local
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
return row ? stored(row) : undefined
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({
id: ID.create(),
+73 -25
View File
@@ -29,15 +29,16 @@ export const When = Schema.Struct({
}).annotate({ identifier: "Integration.When" })
export type When = typeof When.Type
export class TextPrompt extends Schema.Class<TextPrompt>("Integration.TextPrompt")({
export const TextPrompt = Schema.Struct({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
placeholder: Schema.optional(Schema.String),
when: Schema.optional(When),
}) {}
}).annotate({ identifier: "Integration.TextPrompt" })
export type TextPrompt = typeof TextPrompt.Type
export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.SelectPrompt")({
export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
@@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.Select
}),
),
when: Schema.optional(When),
}) {}
}).annotate({ identifier: "Integration.SelectPrompt" })
export type SelectPrompt = typeof SelectPrompt.Type
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
export type Prompt = typeof Prompt.Type
export class OAuthMethod extends Schema.Class<OAuthMethod>("Integration.OAuthMethod")({
export const OAuthMethod = Schema.Struct({
id: MethodID,
type: Schema.Literal("oauth"),
label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)),
}) {}
}).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type
export class KeyMethod extends Schema.Class<KeyMethod>("Integration.KeyMethod")({
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: Schema.optional(Schema.String),
}) {}
}).annotate({ identifier: "Integration.KeyMethod" })
export type KeyMethod = typeof KeyMethod.Type
export class EnvMethod extends Schema.Class<EnvMethod>("Integration.EnvMethod")({
export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"),
names: Schema.Array(Schema.String),
}) {}
}).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
export type Method = typeof Method.Type
@@ -197,7 +202,11 @@ export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]>
readonly connect: {
readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */
readonly key: (input: {
/** Integration receiving the credential. */
@@ -218,6 +227,13 @@ export interface Interface {
/** User-facing label for the credential created on completion. */
readonly label?: string
}) => Effect.Effect<Attempt, AuthorizationError>
/** Updates a stored credential exposed as a connection. */
readonly update: (
credentialID: Credential.ID,
updates: Partial<Pick<Credential.Stored, "label">>,
) => Effect.Effect<void>
/** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
}
readonly attempt: {
/** Returns the current state of an OAuth attempt. */
@@ -328,23 +344,32 @@ export const locationLayer = Layer.effect(
})
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
const connected = saved.map(
(credential) =>
new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }),
)
const connected = saved.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
const detected = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map(
(name, index) =>
new IntegrationConnection.EnvInfo({
type: "env",
name,
}),
)
.map((name) => ({ type: "env" as const, name }))
return [...connected, ...detected]
}
const activeConnection = (
entry: Entry | undefined,
saved: readonly Credential.Stored[],
): IntegrationConnection.Info | undefined => {
const credential = saved.at(-1)
if (credential) return { type: "credential", id: credential.id, label: credential.label }
if (!entry) return
const name = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names)
.find((name) => process.env[name])
if (name) return { type: "env", name }
}
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
new Info({
id: entry.ref.id,
@@ -412,6 +437,7 @@ export const locationLayer = Layer.effect(
return [attempt, new Map(current).set(attemptID, terminal)]
})
if (!result) return settled
if (Exit.isSuccess(settled)) yield* events.publish(Event.Updated, {})
yield* close(result.scope)
return settled
}),
@@ -454,8 +480,21 @@ export const locationLayer = Layer.effect(
}),
)).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connect: {
key: Effect.fn("Integration.connect.key")(function* (input) {
connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id)
return activeConnection(entry, yield* credentials.list(id))
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
.get()
.integrations.get(input.integrationID)
@@ -466,8 +505,9 @@ export const locationLayer = Layer.effect(
label: input.label,
value: new Credential.Key({ type: "key", key: input.key }),
})
yield* events.publish(Event.Updated, {})
}),
oauth: Effect.fn("Integration.connect.oauth")(function* (input) {
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
if (!method) {
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
@@ -510,6 +550,14 @@ export const locationLayer = Layer.effect(
time,
})
}),
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
yield* credentials.update(credentialID, updates)
yield* events.publish(Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
yield* credentials.remove(credentialID)
yield* events.publish(Event.Updated, {})
}),
},
attempt: {
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
+6 -4
View File
@@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection"
import { Schema } from "effect"
import { Credential } from "../credential"
export class CredentialInfo extends Schema.Class<CredentialInfo>("Connection.CredentialInfo")({
export const CredentialInfo = Schema.Struct({
type: Schema.Literal("credential"),
id: Credential.ID,
label: Schema.String,
}) {}
}).annotate({ identifier: "Connection.CredentialInfo" })
export type CredentialInfo = typeof CredentialInfo.Type
export class EnvInfo extends Schema.Class<EnvInfo>("Connection.EnvInfo")({
export const EnvInfo = Schema.Struct({
type: Schema.Literal("env"),
name: Schema.String,
}) {}
}).annotate({ identifier: "Connection.EnvInfo" })
export type EnvInfo = typeof EnvInfo.Type
export const Info = Schema.Union([CredentialInfo, EnvInfo])
.pipe(Schema.toTaggedUnion("type"))
+126
View File
@@ -0,0 +1,126 @@
export function args(command: string) {
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)]
}
const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false);
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);
$OutputEncoding = [Console]::OutputEncoding;
`
function script(command: string) {
const pos = prologue(command)
const head = command.slice(0, pos)
const body = command.slice(pos)
const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : ""
return `${head}${gap}${setup}${body}`
}
function prologue(command: string) {
const pos = scan(command, 0)
const attr = attrs(command, pos)
const body = command.slice(attr)
const match = /^param\s*\(/i.exec(body)
if (!match) return pos
const start = attr + match[0].lastIndexOf("(")
const end = block(command, start, "(", ")")
if (end === undefined) return pos
return end
}
function attrs(command: string, start: number) {
let pos = start
while (pos < command.length) {
const next = scan(command, pos)
if (command[next] !== "[") return next
const end = block(command, next, "[", "]")
if (end === undefined) return start
pos = end
}
return pos
}
function scan(command: string, start: number) {
let pos = start
while (pos < command.length) {
const next = trivia(command, pos)
if (next !== pos) {
pos = next
continue
}
const end = line(command, pos)
const value = command.slice(pos, end)
if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) {
pos = end
continue
}
return pos
}
return pos
}
function trivia(command: string, start: number) {
let pos = start
while (pos < command.length) {
while (/\s/.test(command[pos] ?? "")) pos++
if (command[pos] === "#") {
pos = line(command, pos)
continue
}
if (command.startsWith("<#", pos)) {
const end = command.indexOf("#>", pos + 2)
if (end === -1) return command.length
pos = end + 2
continue
}
return pos
}
return pos
}
function line(command: string, start: number) {
const index = command.indexOf("\n", start)
if (index === -1) return command.length
return index + 1
}
function block(command: string, start: number, open: string, close: string) {
let depth = 0
let quote: string | undefined
for (let pos = start; pos < command.length; pos++) {
const char = command[pos]
if (quote) {
if (quote === "'" && char === "'" && command[pos + 1] === "'") {
pos++
continue
}
if (quote === '"' && char === "`") {
pos++
continue
}
if (char === quote) quote = undefined
continue
}
if (char === "'" || char === '"') {
quote = char
continue
}
if (command.startsWith("<#", pos)) {
const end = command.indexOf("#>", pos + 2)
if (end === -1) return
pos = end + 1
continue
}
if (char === "#") {
pos = line(command, pos) - 1
continue
}
if (char === open) depth++
if (char === close) {
depth--
if (depth === 0) return pos + 1
}
}
}
export const PowerShell = { args }
@@ -0,0 +1,61 @@
import path from "path"
type Input = {
command?: string
args?: string[]
cwd?: string
}
type Command = {
command: string
args: string[]
cwd?: string
}
const names = new Set(["kilo", "kilocode"])
const self = command()
function clean(input: string[]) {
return input.filter((arg, index) => {
if (arg === "--cwd") return false
if (input[index - 1] === "--cwd") return false
if (arg.startsWith("--cwd=")) return false
return true
})
}
function full(input: string, cwd: string) {
if (path.isAbsolute(input)) return input
return path.resolve(cwd, input)
}
export function command(
proc = { argv: process.argv, execArgv: process.execArgv, execPath: process.execPath, cwd: process.cwd() },
): Command {
const script = proc.argv[1]
const bundled = script?.startsWith("/$bunfs/") || (script ? /^[A-Za-z]:[\\/]~BUN[\\/]/.test(script) : false)
if (script && !bundled && /\.(ts|js|mjs|cjs)$/.test(script)) {
const file = full(script, proc.cwd)
const dir = path.dirname(file)
const root = path.basename(dir) === "src" ? path.dirname(dir) : proc.cwd
return { command: full(proc.execPath, proc.cwd), args: [...clean(proc.execArgv), file], cwd: root }
}
return { command: full(proc.execPath, proc.cwd), args: [] }
}
export function resolve(input: Input, cmd = self): Input {
if (!input.command || !names.has(input.command)) return input
const args = input.args ?? []
const project = cmd.cwd && args.length === 0 && input.cwd ? [input.cwd] : []
return {
...input,
command: cmd.command,
args: [...cmd.args, ...project, ...args],
cwd: cmd.cwd ?? input.cwd,
}
}
export const KiloPtySelfCommand = {
command,
resolve,
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
import { AbsolutePath, optionalOmitUndefined } from "./schema"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
@@ -12,7 +12,7 @@ export class Ref extends Schema.Class<Ref>("Location.Ref")({
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
project: Schema.Struct({
id: Project.ID,
directory: AbsolutePath,
-2
View File
@@ -21,7 +21,6 @@ import { PluginV2 } from "../plugin"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
@@ -98,7 +97,6 @@ export const layer = Layer.effect(
})
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
// kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill.
-22
View File
@@ -1,22 +0,0 @@
import { Effect } from "effect"
import { PluginV2 } from "../plugin"
export const EnvPlugin = PluginV2.define({
id: PluginV2.ID.make("env"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
const key = item.provider.env.find((env) => process.env[env])
if (!key) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "env",
name: key,
}
})
}
}),
}
}),
})
+2 -8
View File
@@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({
integrationID,
method: new Integration.KeyMethod({
type: "key",
}),
method: { type: "key" },
})
integrations.method.update({
integrationID,
method: new Integration.EnvMethod({
type: "env",
names: [...item.env],
}),
method: { type: "env", names: [...item.env] },
})
}
})
@@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
provider.name = item.name
provider.env = [...item.env]
provider.api = item.npm
? {
type: "aisdk",
@@ -24,7 +24,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({
apiKey: config.apiKey,
options: gatewayOptions(evt.options, metadata),
} as any)
const unified = createUnified()
const unified = createUnified({ apiKey: config.apiKey })
evt.sdk = {
languageModel(modelID: string) {
return gateway(unified(modelID))
@@ -1,14 +1,17 @@
import { Effect } from "effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" // kilocode_change
export const LLMGatewayPlugin = PluginV2.define({
id: PluginV2.ID.make("llmgateway"),
effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.enabled === false) continue
if (item.provider.disabled) continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
@@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
export const browser = {
integrationID: Integration.ID.make("openai"),
method: new Integration.OAuthMethod({
method: {
id: browserMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
}),
},
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
@@ -95,11 +95,11 @@ export const browser = {
export const headless = {
integrationID: Integration.ID.make("openai"),
method: new Integration.OAuthMethod({
method: {
id: headlessMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
}),
},
authorize: () =>
Effect.gen(function* () {
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
@@ -1,20 +1,20 @@
import { Effect } from "effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const OpencodePlugin = PluginV2.define({
id: PluginV2.ID.make("opencode"),
effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
let hasKey = false
return {
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
hasKey = Boolean(
process.env.OPENCODE_API_KEY ||
item.provider.env.some((env) => process.env[env]) ||
item.provider.request.body.apiKey ||
(item.provider.enabled && item.provider.enabled.via === "credential"),
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
)
evt.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.request.body.apiKey = "public"
+1 -1
View File
@@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({
skill: new SkillV2.Info({
name: "customize-opencode",
description:
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CustomizeOpencodeContent,
}),
@@ -43,6 +43,8 @@ already-loaded config until then.
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
| Project commands | `.opencode/command/<name>.md` or `.opencode/commands/<name>.md` |
| Global commands | `~/.config/opencode/command(s)/<name>.md` |
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
@@ -96,7 +98,7 @@ Every field is optional.
},
"command": {
"deploy": { "description": "...", "prompt": "..." }
"deploy": { "description": "...", "template": "..." }
},
"provider": {
@@ -151,6 +153,7 @@ Shape notes worth being explicit about:
- `skills` is an object with `paths` and/or `urls`, not an array.
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
- `agent` is an object keyed by agent name, not an array.
- `command` is an object keyed by command name, not an array.
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
- `permission` is either a string action or an object keyed by tool name.
@@ -277,6 +280,31 @@ opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agent
`compaction`, `title`, `summary`. To override a built-in's fields, define the
same key in `agent: { <name>: { ... } }`.
## Commands
opencode's command loader scans for `**/*.md` inside command directories. The
file is named after the command, and lives directly inside the `command` folder:
```
.opencode/command/deploy.md
```
Frontmatter:
```markdown
---
description: One sentence describing what the command does.
agent: build
model: anthropic/claude-sonnet-4-6
---
(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input)
```
- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
## Plugins
`plugin:` is an array. Each entry is one of:
@@ -300,7 +328,7 @@ function, not a plain object literal, and the function returns an object
(return `{}` if there is nothing to register).
```ts
import type { Plugin } from "@opencode-ai/plugin"
import type { Plugin } from "@kilocode/plugin"
export default (async ({ client, project, directory, $ }) => {
return {
@@ -397,16 +425,16 @@ the `plan` agent's permission ruleset (`edit: deny *`).
When a user's config is broken and opencode won't start, these env vars help:
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
- `KILO_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
and start from globals only. Run from the project directory, opencode loads,
the user edits the broken file, then they restart without the flag.
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
- `KILO_CONFIG=/path/to/file.json`: load an additional explicit config.
- `KILO_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
inject inline JSON as a final local-scope merge.
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
- `OPENCODE_PURE=1`: skip external plugins entirely.
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
- `KILO_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
- `KILO_PURE=1`: skip external plugins entirely.
- `KILO_DISABLE_EXTERNAL_SKILLS=1`,
`KILO_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
`~/.claude/` and `~/.agents/`.
## When proposing edits
@@ -415,8 +443,8 @@ When a user's config is broken and opencode won't start, these env vars help:
exact shape, or the field is not covered in this skill, fetch
`https://opencode.ai/config.json` and read the schema rather than guessing.
- Preserve `$schema` and any existing fields the user did not ask to change.
- For agent, skill, and plugin definitions, prefer creating new files in the
correct location over inlining everything in `opencode.json`.
- For agent, command, skill, and plugin definitions, prefer creating new files
in the correct location over inlining everything in `opencode.json`.
- If the user's existing config is malformed, point them at the env-var escape
hatches above so they can edit from inside opencode without breaking their
session.
+1
View File
@@ -245,6 +245,7 @@ export const layer = Layer.effect(
(sourceDirectory) =>
Effect.forEach(strategies(), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
+1 -19
View File
@@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider"
import { withStatics } from "./schema"
import { Schema } from "effect"
import { Credential } from "./credential"
export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"),
@@ -49,22 +48,7 @@ export type Request = typeof Request.Type
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
id: ID,
name: Schema.String,
enabled: Schema.Union([
Schema.Literal(false),
Schema.Struct({
via: Schema.Literal("env"),
name: Schema.String,
}),
Schema.Struct({
via: Schema.Literal("credential"),
credentialID: Credential.ID,
}),
Schema.Struct({
via: Schema.Literal("custom"),
data: Schema.Record(Schema.String, Schema.Any),
}),
]),
env: Schema.String.pipe(Schema.Array),
disabled: Schema.Boolean.pipe(Schema.optional),
api: Api,
request: Request,
}) {
@@ -72,8 +56,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
return new Info({
id: providerID,
name: providerID,
enabled: false,
env: [],
api: {
type: "native",
settings: {},
+155 -107
View File
@@ -2,23 +2,29 @@ export * as Pty from "./pty"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Config } from "./config"
import { EventV2 } from "./event"
import { Location } from "./location"
import { NonNegativeInt, PositiveInt } from "./schema"
import { PtyID } from "./pty/schema"
import { SessionSchema } from "./session/schema" // kilocode_change
import { Shell } from "./shell"
import { lazy } from "./util/lazy"
import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
// Cap retention so abandoned terminals do not accumulate unbounded buffers.
const EXITED_LIMIT = 25
const pty = lazy(() => import("#pty"))
type Socket = {
readyState: number
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
type Subscriber = {
readonly onData: (chunk: string) => void
readonly onEnd: (event: { exitCode?: number }) => void
active: boolean
detached: boolean
pending: string[]
end?: { exitCode?: number }
}
type Active = {
@@ -27,22 +33,10 @@ type Active = {
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<unknown, Socket>
subscribers: Map<object, Subscriber>
listeners: Disp[]
}
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const bytes = encoder.encode(json)
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
export const Info = Schema.Struct({
id: PtyID,
title: Schema.String,
@@ -52,6 +46,8 @@ export const Info = Schema.Struct({
status: Schema.Literals(["running", "exited"]),
// Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
pid: NonNegativeInt,
// Present once status is "exited".
exitCode: Schema.optional(NonNegativeInt),
sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change
}).annotate({ identifier: "Pty" })
@@ -67,14 +63,6 @@ export const CreateInput = Schema.Struct({
export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
export type PreparedCreate = {
readonly command: string
readonly args: string[]
readonly cwd: string
readonly title?: string
readonly env: Record<string, string>
}
export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String),
sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change
@@ -88,10 +76,34 @@ export const UpdateInput = Schema.Struct({
export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
export type AttachInput = {
// Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer.
readonly cursor?: number
// Callbacks fire synchronously from the native PTY data path; keep them non-blocking.
readonly onData: (chunk: string) => void
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
readonly onEnd: (event: { exitCode?: number }) => void
}
export type Attachment = {
// Retained output from the requested cursor to the current end.
readonly replay: string
// Absolute output cursor after replay.
readonly cursor: number
readonly write: (data: string) => void
// Starts live delivery after the caller has applied replay and cursor metadata.
readonly activate: () => void
readonly detach: () => void
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID,
}) {}
export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.ExitedError", {
ptyID: PtyID,
}) {}
export const Event = {
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
@@ -102,19 +114,11 @@ export const Event = {
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: PreparedCreate) => Effect.Effect<Info>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
NotFoundError
>
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
@@ -124,28 +128,41 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const events = yield* EventV2.Service
const location = yield* Location.Service
const config = yield* Config.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
const exitOrder: PtyID[] = []
function notifyEnd(session: Active, event: { exitCode?: number }) {
for (const subscriber of session.subscribers.values()) {
if (!subscriber.active) {
subscriber.end = event
continue
}
try {
subscriber.onEnd(event)
} catch {}
}
session.subscribers.clear()
}
function teardown(session: Active) {
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
try {
session.process.kill()
} catch {}
for (const [sub, ws] of session.subscribers.entries()) {
if (session.info.status === "running") {
try {
if (sock(ws) === sub) ws.close()
session.process.kill()
} catch {}
}
session.subscribers.clear()
notifyEnd(session, {})
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) teardown(session)
sessions.clear()
exitOrder.length = 0
}),
)
@@ -157,12 +174,13 @@ export const layer = Layer.effect(
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return false
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
return true
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
@@ -178,29 +196,47 @@ export const layer = Layer.effect(
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
// kilocode_change start - resolve Kilo self-commands to the real binary, arguments, and project cwd
const resolved = KiloPtySelfCommand.resolve({
command: input.command,
args: input.args ? [...input.args] : undefined,
cwd: input.cwd,
})
const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
const base = resolved.args ?? []
const args = Shell.login(command) ? [...base, "-l"] : [...base]
const cwd = resolved.cwd || location.directory
// kilocode_change end
const env = {
...process.env,
...input.env,
TERM: "xterm-256color",
KILO_TERMINAL: "1",
KILO_PTY_ID: id, // kilocode_change - let nested Kilo processes identify their parent terminal
} as Record<string, string>
// kilocode_change start - do not expose the local server credential to processes spawned by user terminals
delete env.KILO_SERVER_PASSWORD
delete env.KILO_SERVER_USERNAME
// kilocode_change end
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty())
// kilocode_change - expose the pty id to the spawned shell so a nested `kilo tui`/`kilo run` can
// detect it is running inside a kilo-spawned terminal (read via process.env.KILO_PTY_ID)
const env = { ...input.env, KILO_PTY_ID: id }
const proc = yield* Effect.sync(() =>
spawn(input.command, input.args, {
name: "xterm-256color",
cwd: input.cwd,
env,
}),
)
const info = {
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
const info: Info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command: input.command,
args: input.args,
cwd: input.cwd,
command,
args,
cwd,
status: "running",
pid: proc.pid,
} as const
}
const session: Active = {
info,
process: proc,
@@ -214,15 +250,15 @@ export const layer = Layer.effect(
session.listeners.push(
proc.onData((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1 || sock(ws) !== key) {
session.subscribers.delete(key)
for (const [token, subscriber] of session.subscribers.entries()) {
if (!subscriber.active) {
subscriber.pending.push(chunk)
continue
}
try {
ws.send(chunk)
subscriber.onData(chunk)
} catch {
session.subscribers.delete(key)
session.subscribers.delete(token)
}
}
session.buffer += chunk
@@ -233,12 +269,19 @@ export const layer = Layer.effect(
}),
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
session.info.status = "exited"
session.info.exitCode = exitCode
notifyEnd(session, { exitCode })
exitOrder.push(id)
runFork(
Effect.gen(function* () {
yield* Effect.logInfo("session exited", { id, exitCode })
session.info.status = "exited"
yield* events.publish(Event.Exited, { id, exitCode })
yield* removeSession(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(oldest)
}
}),
)
}),
@@ -253,66 +296,71 @@ export const layer = Layer.effect(
// kilocode_change start - associate nested Kilo TUI terminals with the viewed session
if ("sessionID" in input) session.info.sessionID = input.sessionID ?? undefined
// kilocode_change end
if (input.size) session.process.resize(input.size.cols, input.size.rows)
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
yield* events.publish(Event.Updated, { info: session.info })
return session.info
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.resize(cols, rows)
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.write(data)
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
const sub = sock(ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
const cleanup = () => session.subscribers.delete(sub)
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
const token = {}
const subscriber: Subscriber = {
onData: input.onData,
onEnd: input.onEnd,
active: false,
detached: false,
pending: [],
}
session.subscribers.set(token, subscriber)
const start = session.bufferCursor
const end = session.cursor
const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => {
input.cursor === -1
? end
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
? Math.max(0, input.cursor)
: 0
const replay = (() => {
if (!session.buffer || from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK))
} catch {
cleanup()
ws.close()
return
}
}
try {
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
replay,
cursor: end,
write: (data: string) => {
if (session.info.status === "running") session.process.write(data)
},
onClose: () => {
cleanup()
activate: () => {
if (subscriber.active || subscriber.detached) return
subscriber.active = true
try {
for (const chunk of subscriber.pending) subscriber.onData(chunk)
subscriber.pending.length = 0
if (subscriber.end) subscriber.onEnd(subscriber.end)
} catch {
session.subscribers.delete(token)
}
},
detach: () => {
subscriber.detached = true
subscriber.pending.length = 0
subscriber.end = undefined
session.subscribers.delete(token)
},
}
})
return Service.of({ list, get, create, update, remove, resize, write, connect })
return Service.of({ list, get, create, update, remove, write, attach })
}),
)
export const locationLayer = layer
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
-24
View File
@@ -1,24 +0,0 @@
import { Effect } from "effect"
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
export function handlePtyInput(
handler: { onMessage: (message: string | ArrayBuffer) => void },
message: string | Uint8Array,
) {
if (typeof message === "string") {
handler.onMessage(message)
return Effect.void
}
return Effect.try({
try: () => inputDecoder.decode(message),
catch: () => new Error("invalid PTY websocket input"),
}).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.flatMap((decoded) => {
if (decoded === undefined) return Effect.void
handler.onMessage(decoded)
return Effect.void
}),
)
}
+37
View File
@@ -0,0 +1,37 @@
export * as PtyProtocol from "./protocol"
// Wire protocol for PTY websocket transports. The PTY domain service is transport-free; server
// routes adapt Pty.attach to websockets with these helpers so every surface speaks one protocol.
//
// Outbound frames are raw UTF-8 terminal chunks. One control frame — a 0x00 byte followed by
// UTF-8 JSON — carries the absolute output cursor after replay so clients can resume later.
const encoder = new TextEncoder()
const decoder = new TextDecoder("utf-8", { fatal: true })
// Replay can be megabytes; send it in bounded frames.
export const REPLAY_CHUNK = 64 * 1024
export function metaFrame(cursor: number) {
const bytes = encoder.encode(JSON.stringify({ cursor }))
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
export function chunks(data: string) {
const out: string[] = []
for (let i = 0; i < data.length; i += REPLAY_CHUNK) out.push(data.slice(i, i + REPLAY_CHUNK))
return out
}
// Inbound client frames are UTF-8 text or binary; invalid UTF-8 input is dropped.
export function decodeInput(message: string | Uint8Array | ArrayBuffer) {
if (typeof message === "string") return message
try {
return decoder.decode(message instanceof ArrayBuffer ? new Uint8Array(message) : message)
} catch {
return undefined
}
}
+1 -10
View File
@@ -1,7 +1,7 @@
export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm"
import { Context, Effect, Schema } from "effect"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema"
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
@@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store"
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"SessionRunner.StepLimitExceededError",
{
sessionID: SessionSchema.ID,
limit: Schema.Int,
},
) {}
export type RunError =
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| ContextSnapshotDecodeError
| StepLimitExceededError
| SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error
+26 -22
View File
@@ -3,6 +3,7 @@ import {
LLMClient,
LLMError,
LLMEvent,
Message,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
@@ -29,10 +30,11 @@ import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { type RunError, Service, StepLimitExceededError } from "./index"
import { type RunError, Service } from "./index"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
/**
* Runs one durable coding-agent Session until it settles.
@@ -45,7 +47,7 @@ import { toLLMMessages } from "./to-llm-message"
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
* - [x] Bound model steps.
* - [x] Honor optional agent step limits.
* - [ ] Bound provider retries and repeated identical tool calls.
*
* - Runtime context assembly
@@ -80,13 +82,10 @@ import { toLLMMessages } from "./to-llm-message"
* Durable activity recovery remains a separate future slice with an explicit retry policy.
*
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and a
* bounded explicit loop starts the next provider turn after local settlement.
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and an
* explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop.
*/
// QUESTION: Did this exist previously, or did we add this limit? Does it make sense?
const MAX_STEPS = 25
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -175,6 +174,7 @@ export const layer = Layer.effect(
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
step: number,
recoverOverflow?: typeof compaction.compactAfterOverflow,
) {
const session = yield* getSession(sessionID)
@@ -214,7 +214,8 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@@ -222,8 +223,9 @@ export const layer = Layer.effect(
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(context, model),
tools: toolMaterialization.definitions,
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn())
@@ -254,6 +256,10 @@ export const layer = Layer.effect(
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
return
}
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) =>
@@ -340,31 +346,32 @@ export const layer = Layer.effect(
type RunTurn = (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
step: number,
) => Effect.Effect<boolean, RunError>
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
return yield* runTurnAttempt(sessionID, promotion).pipe(
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
return yield* runTurnAttempt(sessionID, promotion, step).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion)
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
}),
),
)
})
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe(
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined)
return yield* runTurn(sessionID, defect.transition.promotion)
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
return yield* runTurn(sessionID, defect.transition.promotion, step)
}),
),
)
@@ -382,14 +389,11 @@ export const layer = Layer.effect(
let openActivity = input.force === true || hasSteer || hasQueue
while (openActivity) {
let needsContinuation = true
for (let step = 0; step < MAX_STEPS; step++) {
needsContinuation = yield* runTurn(input.sessionID, promotion)
for (let step = 1; needsContinuation; step++) {
needsContinuation = yield* runTurn(input.sessionID, promotion, step)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
if (!needsContinuation) break
}
if (needsContinuation)
return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS })
openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
promotion = openActivity ? "queue" : undefined
}
@@ -1,4 +1,4 @@
CRITICAL - MAXIMUM STEPS REACHED
export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED
The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only.
@@ -13,4 +13,4 @@ Response must include:
- List of any remaining tasks that were not completed
- Recommendations for what should be done next
Any attempt to use tools is a critical violation. Respond with text ONLY.
Any attempt to use tools is a critical violation. Respond with text ONLY.`
+44 -19
View File
@@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { produce } from "immer"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { IntegrationConnection } from "../../integration/connection"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginBoot } from "../../plugin/boot"
@@ -45,10 +48,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
if (credential?.value.type === "key") return Auth.value(credential.value.key)
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
return connection?.type === "env" ? Auth.config(connection.name) : undefined
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
@@ -83,41 +88,54 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = (
model: ModelV2.Info,
provider?: ProviderV2.Info,
connection?: IntegrationConnection.Info,
credential?: Credential.Stored,
): Effect.Effect<Model, UnsupportedApiError> => {
const key = apiKey(model, provider)
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
const resolved =
credential?.value.metadata === undefined
? model
: produce(model, (draft) => {
Object.assign(draft.request.body, credential.value.metadata)
// kilocode_change start - Kilo Gateway consumes the migrated OAuth account as its organization route
if (credential.value.type === "oauth" && credential.value.metadata?.accountID) {
draft.request.body.kilocodeOrganizationId = credential.value.metadata.accountID
delete draft.request.body.accountID
}
// kilocode_change end
})
const key = apiKey(resolved, connection, credential)
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
return Effect.succeed(
withDefaults(model, OpenAIResponses.route)
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: model.api.id }),
.model({ id: resolved.api.id }),
)
}
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(model, AnthropicMessages.route)
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: model.api.id }),
.model({ id: resolved.api.id }),
)
}
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
return Effect.succeed(
withDefaults(model, OpenAICompatibleChat.route)
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: model.api.id }),
.model({ id: resolved.api.id }),
)
}
return Effect.fail(
new UnsupportedApiError({
providerID: model.providerID,
modelID: model.id,
api: apiName(model),
providerID: resolved.providerID,
modelID: resolved.id,
api: apiName(resolved),
}),
)
}
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
fromCatalogModel(withVariant(model, session.model?.variant), provider)
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
fromCatalogModel(withVariant(model, session.model?.variant))
export const supported = (model: ModelV2.Info) =>
model.api.type === "aisdk" &&
@@ -130,6 +148,8 @@ export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const boot = yield* PluginBoot.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
@@ -140,7 +160,12 @@ export const locationLayer = Layer.effect(
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
(yield* catalog.model.available()).find(supported))
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
return yield* fromCatalogModel(
withVariant(selected, session.model?.variant),
connection,
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
)
}),
})
}),
@@ -1,11 +1,14 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import * as PowerShell from "@/kilocode/shell/shell" // kilocode_change - PowerShell args
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { which } from "@opencode-ai/core/util/which"
export * as Shell from "./shell"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout as sleep } from "node:timers/promises"
import { Flag } from "./flag/flag"
import { FSUtil } from "./fs-util"
import { which } from "./util/which"
import { PowerShell } from "./kilocode/powershell" // kilocode_change
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
@@ -48,7 +51,7 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
} catch {
proc.kill("SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
@@ -57,9 +60,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
}
}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function full(file: string) {
if (process.platform !== "win32") return file
const shell = Filesystem.windowsPath(file)
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
@@ -77,13 +84,13 @@ function ok(file: string) {
}
function rooted(file: string) {
return path.isAbsolute(Filesystem.windowsPath(file))
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string) {
const shell = full(file)
if (rooted(shell)) {
if (Filesystem.stat(shell)?.isFile()) return shell
if (stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
@@ -100,7 +107,7 @@ function win() {
}
async function unix() {
const text = await Filesystem.readText("/etc/shells").catch(() => "")
const text = await readFile("/etc/shells", "utf8").catch(() => "")
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
@@ -110,7 +117,7 @@ function select(file: string | undefined, opts?: { acceptable?: boolean }) {
const shell = resolve(file)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]!
if (process.platform === "win32") return win()[0]
return fallback()
}
@@ -120,7 +127,7 @@ export function gitbash() {
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (Filesystem.stat(file)?.size) return file
if (stat(file)?.size) return file
}
function fallback() {
@@ -131,7 +138,7 @@ function fallback() {
}
export function name(file: string) {
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
return path.basename(file).toLowerCase()
}
@@ -189,28 +196,32 @@ export function args(file: string, command: string, cwd: string) {
]
}
if (n === "cmd") return ["/c", command]
if (ps(file)) return PowerShell.args(command) // kilocode_change - PowerShell args
if (ps(file)) return PowerShell.args(command) // kilocode_change - preserve UTF-8 and script prologues
return ["-c", command]
}
const defaultPreferred = lazy(() => select(process.env.SHELL))
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
return defaultPreferred()
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
}
preferred.reset = () => {
defaultPreferred = undefined
}
preferred.reset = () => defaultPreferred.reset()
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
return defaultAcceptable()
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
return defaultAcceptable
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
acceptable.reset = () => defaultAcceptable.reset()
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
}
export * as Shell from "./shell"
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+55 -52
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@@ -25,47 +25,29 @@ const it = testEffect(
Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
),
),
)
describe("CatalogV2", () => {
it.effect("projects Kilo organization routing from OAuth credentials", () => {
const integrationID = Integration.ID.make("kilocode")
const credential = new Credential.Stored({
id: Credential.ID.create(),
integrationID,
label: "Organization",
value: new Credential.OAuth({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "refresh",
expires: 1,
metadata: { accountID: "organization" },
}),
})
const layer = Catalog.locationLayer.pipe(
Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(
Layer.mock(Credential.Service)({ all: () => Effect.succeed([credential]) }),
),
)
return Effect.gen(function* () {
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("kilocode"), () => {}))
expect(yield* catalog.provider.get(ProviderV2.ID.make("kilocode"))).toMatchObject({
request: { body: { apiKey: "access", kilocodeOrganizationId: "organization" } },
})
}).pipe(Effect.provide(layer))
})
const events = yield* EventV2.Service
const updated = yield* events
.subscribe(Catalog.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
it.effect("projects active credentials without rebuilding catalog state", () => {
yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect((yield* Fiber.join(updated)).length).toBe(1)
}),
)
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const first = {
id: Credential.ID.create(),
@@ -87,6 +69,7 @@ describe("CatalogV2", () => {
Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.sync(() => [active]),
list: () => Effect.sync(() => [active]),
}),
),
)
@@ -96,18 +79,44 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: first.id },
request: { body: { apiKey: "first", tenant: "one" } },
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
active = second
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: second.id },
request: { body: { apiKey: "second", tenant: "two" } },
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer))
})
it.effect("projects environment connections without a catalog plugin", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.CATALOG_TEST_API_KEY
process.env.CATALOG_TEST_API_KEY = "secret"
return previous
}),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = ProviderV2.ID.make("test")
yield* integrations.update((editor) =>
editor.method.update({
integrationID: Integration.ID.make(providerID),
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
}),
)
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
else process.env.CATALOG_TEST_API_KEY = previous
}),
),
)
it.effect("normalizes provider baseURL into api url", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -326,9 +335,7 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
@@ -350,9 +357,7 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform()
const models = (catalog: Catalog.Editor) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, old, (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
@@ -383,12 +388,10 @@ describe("CatalogV2", () => {
yield* transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => {
provider.enabled = false
provider.disabled = true
})
catalog.model.update(disabledProvider, disabledModel, () => {})
catalog.provider.update(enabledProvider, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.provider.update(enabledProvider, () => {})
catalog.model.update(enabledProvider, fallbackModel, () => {})
catalog.model.default.set(disabledProvider, disabledModel)
})
+116 -103
View File
@@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it } from "../plugin/provider-helper"
import { it, withEnv } from "../plugin/provider-helper"
function request(headers: Record<string, string>, variant?: string) {
return {
@@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("partitions existing model variant bodies without changing config shape", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
@@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
@@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("uses the effective provider package across layered config", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
@@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
@@ -131,118 +136,126 @@ describe("ConfigProviderPlugin.Plugin", () => {
)
it.effect("loads configured providers and applies later model overrides", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
api: { type: "native", settings: {} },
request: request({ first: "first", shared: "first" }),
models: {
chat: {
name: "First",
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
request: request({ first: "first", shared: "first" }, "retained"),
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
},
],
withEnv({ CUSTOM_API_KEY: "secret" }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
api: { type: "native", settings: {} },
request: request({ first: "first", shared: "first" }),
models: {
chat: {
name: "First",
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
request: request({ first: "first", shared: "first" }, "retained"),
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
},
],
},
},
},
},
},
}),
}),
}),
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
providers: {
custom: {
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
request: request({ last: "last", shared: "last" }),
models: {
default: {
name: "Default",
},
chat: {
api: { id: "api-chat" },
name: "Last",
limit: { output: 75 },
request: request({ last: "last", shared: "last" }),
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
},
{
id: "slow",
headers: { slow: "slow" },
},
],
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
providers: {
custom: {
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
request: request({ last: "last", shared: "last" }),
models: {
default: {
name: "Default",
},
chat: {
api: { id: "api-chat" },
name: "Last",
limit: { output: 75 },
request: request({ last: "last", shared: "last" }),
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
},
{
id: "slow",
headers: { slow: "slow" },
},
],
},
},
},
},
},
}),
}),
}),
new Config.Document({
type: "document",
info: decode({
providers: {
custom: { name: "Renamed" },
},
new Config.Document({
type: "document",
info: decode({
providers: {
custom: { name: "Renamed" },
},
}),
}),
}),
]),
})
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
expect(provider.enabled).toEqual({ via: "custom", data: {} })
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.request.variant).toBe("retained")
expect(model.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}),
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
type: "env",
names: ["CUSTOM_API_KEY"],
})
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
expect(provider.disabled).toBeUndefined()
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.request.variant).toBe("retained")
expect(model.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}),
),
)
})
+31 -21
View File
@@ -1,8 +1,7 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Exit, Layer, Scope } from "effect"
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration"
import { IntegrationConnection } from "@opencode-ai/core/integration/connection"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
@@ -25,7 +24,7 @@ function connectionLayer(
}>,
) {
return Integration.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: (input) =>
@@ -103,7 +102,7 @@ describe("Integration", () => {
.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize,
}),
)
@@ -117,7 +116,7 @@ describe("Integration", () => {
])
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }),
method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
authorize,
})
})
@@ -140,15 +139,20 @@ describe("Integration", () => {
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service
const events = yield* EventV2.Service
const integrationID = Integration.ID.make("openai")
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.KeyMethod({ type: "key", label: "API key" }),
method: { type: "key", label: "API key" },
}),
)
const updated = yield* events
.subscribe(Integration.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* integrations.connect.key({
yield* integrations.connection.key({
integrationID,
key: "secret",
label: "Work",
@@ -161,6 +165,7 @@ describe("Integration", () => {
value: new Credential.Key({ type: "key", key: "secret" }),
},
])
expect((yield* Fiber.join(updated)).length).toBe(1)
}).pipe(Effect.provide(connectionLayer(created)))
})
@@ -177,7 +182,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
@@ -198,7 +203,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({
const attempt = yield* integrations.connection.oauth({
integrationID,
methodID,
inputs: {},
@@ -236,7 +241,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
@@ -249,7 +254,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
Integration.CodeRequiredError,
)
@@ -273,7 +278,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.succeed({
mode: "auto" as const,
@@ -286,7 +291,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
yield* Effect.yieldNow
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "complete",
@@ -310,7 +315,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
@@ -323,7 +328,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
yield* TestClock.adjust(Duration.minutes(10))
yield* Effect.yieldNow
@@ -373,23 +378,28 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.EnvMethod({
method: {
type: "env",
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
}),
},
}),
)
// Stored credentials and detected env vars appear as connections.
expect((yield* integrations.get(integrationID))?.connections).toEqual([
new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }),
new IntegrationConnection.CredentialInfo({
{ type: "credential", id: rows[0]!.id, label: "Work" },
{
type: "credential",
id: rows[1]!.id,
label: "Personal",
}),
new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }),
},
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
])
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
type: "credential",
id: rows[1]!.id,
label: "Personal",
})
}).pipe(Effect.provide(projectionLayer)),
(previous) =>
Effect.sync(() => {
@@ -0,0 +1,56 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { it } from "../lib/effect"
describe("SessionRunnerModel Kilo credentials", () => {
it.effect("maps OAuth account IDs to Kilo organization routing", () =>
Effect.gen(function* () {
const model = new ModelV2.Info({
id: ModelV2.ID.make("test-model"),
providerID: ProviderV2.ID.make("kilo"),
name: "Test model",
api: {
id: ModelV2.ID.make("api-test-model"),
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.kilo.ai/openrouter",
},
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: { headers: {}, body: {}, generation: {}, options: {} },
variants: [],
time: { released: DateTime.makeUnsafe(0) },
cost: [],
status: "active",
enabled: true,
limit: { context: 100, output: 20 },
})
const credential = new Credential.Stored({
id: Credential.ID.create(),
integrationID: Integration.ID.make("kilo"),
label: "Work",
value: new Credential.OAuth({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
refresh: "refresh",
access: "access",
expires: 1,
metadata: { accountID: "org-enterprise" },
}),
})
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model,
{ type: "credential", id: credential.id, label: credential.label },
credential,
)
expect(resolved.route.defaults.http?.body).toMatchObject({ kilocodeOrganizationId: "org-enterprise" })
expect(resolved.route.defaults.http?.body).not.toHaveProperty("accountID")
}),
)
})
+6 -4
View File
@@ -28,8 +28,10 @@ const connections = Credential.layer.pipe(
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(events),
)
const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections)))
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
)
const layer = Layer.mergeAll(
catalog.pipe(Layer.provide(connections)),
integrations,
@@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => {
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
new Integration.KeyMethod({ type: "key" }),
new Integration.EnvMethod({
{ type: "key" },
{
type: "env",
names: ["ACME_API_KEY"],
}),
},
],
connections: [],
}),
@@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe(
Layer.provide(
Layer.mock(Credential.Service)({
create: () => Effect.die("unexpected credential creation"),
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
),
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
@@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("LLMGatewayPlugin", () => {
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
const integrations = yield* Integration.Service
yield* plugin.add({
...LLMGatewayPlugin,
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
})
})
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
@@ -21,31 +30,29 @@ describe("LLMGatewayPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
yield* add(plugin)
const integrations = yield* Integration.Service
yield* integrations.update((editor) => {
editor.update(Integration.ID.make("llmgateway"), () => {})
editor.update(Integration.ID.make("openrouter"), () => {})
})
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const llmgateway = provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
request: { headers: { Existing: "value" }, body: {} },
})
catalog.provider.update(llmgateway.id, (draft) => {
draft.enabled = llmgateway.enabled
draft.api = llmgateway.api
draft.request = llmgateway.request
})
const openrouter = provider("openrouter", {
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
})
catalog.provider.update(openrouter.id, (draft) => {
draft.enabled = openrouter.enabled
})
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://kilo.ai/",
"X-Title": "Kilo Code",
"X-Source": "kilo",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-Source": "opencode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
}),
@@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
yield* add(plugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("llmgateway", {
@@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => {
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
}),
)
@@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => {
const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service)
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
new Integration.OAuthMethod({
{
id: Integration.MethodID.make("chatgpt-browser"),
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
}),
new Integration.OAuthMethod({
},
{
id: Integration.MethodID.make("chatgpt-headless"),
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
}),
},
])
}),
)
@@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -18,13 +19,18 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const pluginWithIntegrations = (integrations: Integration.Interface) => ({
...OpencodePlugin,
effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
})
describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@@ -45,7 +51,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@@ -66,7 +72,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@@ -87,7 +93,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@@ -108,13 +114,18 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const integrations = yield* Integration.Service
yield* plugin.add(pluginWithIntegrations(integrations))
yield* integrations.update((editor) => {
editor.method.update({
integrationID: Integration.ID.make("opencode"),
method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
})
})
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
catalog.provider.update(item.id, (draft) => {
draft.env = [...item.env]
})
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
@@ -131,7 +142,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", {
@@ -154,37 +165,12 @@ describe("OpencodePlugin", () => {
),
)
it.effect("uses auth-enabled providers as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", {
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
})
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}),
),
)
it.effect("ignores non-opencode providers and models", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openai")
+12
View File
@@ -365,6 +365,18 @@ describe("ProjectCopy", () => {
}),
)
it.live("refresh ignores existing directories that are no longer git checkouts", () =>
Effect.gen(function* () {
const input = yield* setup()
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
const copy = yield* ProjectCopy.Service
yield* copy.refresh({ projectID: input.projectID })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
)
it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () {
const copy = yield* ProjectCopy.Service
@@ -24,4 +24,9 @@ describe("Pty.Info", () => {
test("rejects a negative pid", () => {
expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow()
})
test("accepts an exit code for retained exited sessions", () => {
const info = Schema.decodeUnknownSync(Pty.Info)({ ...sample(48012), status: "exited", exitCode: 4 })
expect(info.exitCode).toBe(4)
})
})
-19
View File
@@ -1,19 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { handlePtyInput } from "@opencode-ai/core/pty/input"
import { it } from "../lib/effect"
describe("pty websocket input", () => {
it.effect("does not forward invalid binary frames to the PTY handler", () =>
Effect.gen(function* () {
const messages: Array<string | ArrayBuffer> = []
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
yield* handlePtyInput(handler, "ready")
yield* handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd]))
yield* handlePtyInput(handler, new TextEncoder().encode("hello"))
expect(messages).toEqual(["ready", "hello"])
}),
)
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
describe("pty protocol", () => {
test("drops invalid binary input frames and decodes valid ones", () => {
expect(PtyProtocol.decodeInput("ready")).toBe("ready")
expect(PtyProtocol.decodeInput(new Uint8Array([0xff, 0xfe, 0xfd]))).toBeUndefined()
expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello"))).toBe("hello")
expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello").buffer)).toBe("hello")
})
test("encodes the cursor as a 0x00-prefixed JSON control frame", () => {
const frame = PtyProtocol.metaFrame(42)
expect(frame[0]).toBe(0)
expect(JSON.parse(new TextDecoder().decode(frame.subarray(1)))).toEqual({ cursor: 42 })
})
test("splits replay into bounded frames", () => {
expect(PtyProtocol.chunks("")).toEqual([])
expect(PtyProtocol.chunks("abc")).toEqual(["abc"])
const big = "x".repeat(PtyProtocol.REPLAY_CHUNK + 1)
const frames = PtyProtocol.chunks(big)
expect(frames.length).toBe(2)
expect(frames[0].length).toBe(PtyProtocol.REPLAY_CHUNK)
expect(frames.join("")).toBe(big)
})
})
@@ -1,110 +0,0 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, Queue } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
type Socket = Parameters<Pty.Interface["connect"]>[1]
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (command: string) {
const pty = yield* Pty.Service
return yield* Effect.acquireRelease(
pty.create({ command, args: [], cwd: "/tmp", env: { TERM: "xterm-256color", KILO_TERMINAL: "1" } }),
(info) => pty.remove(info.id).pipe(Effect.ignore),
)
})
const decodeOutput = (data: string | Uint8Array | ArrayBuffer) =>
typeof data === "string"
? data
: Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8")
const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) {
const output = yield* Queue.unbounded<string>()
const socket: Socket = {
readyState: 1,
data,
send: (data) => Queue.offerUnsafe(output, decodeOutput(data)),
close: () => {},
}
return { socket, output }
})
const waitForOutput = (output: Queue.Queue<string>, text: string, duration: Duration.Input = "5 seconds") =>
Effect.gen(function* () {
let received = ""
while (!received.includes(text)) received += yield* Queue.take(output)
return received
}).pipe(
Effect.timeoutOrElse({
duration,
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
describe("pty output isolation", () => {
ptyTest("does not leak output when websocket objects are reused", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty("cat")
const b = yield* createPty("cat")
const shared = yield* makeSocket({ events: { connection: "a" } })
const outB = yield* Queue.unbounded<string>()
yield* pty.connect(a.id, shared.socket)
shared.socket.data = { events: { connection: "b" } }
shared.socket.send = (data) => Queue.offerUnsafe(outB, decodeOutput(data))
yield* pty.connect(b.id, shared.socket)
yield* pty.write(a.id, "AAA\n")
const verify = yield* makeSocket({ events: { connection: "verify-a" } })
yield* pty.connect(a.id, verify.socket)
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
expect(yield* waitForOutput(outB, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
}),
)
ptyTest("does not leak output when Bun recycles websocket objects before re-connect", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
const first = yield* makeSocket({ events: { connection: "a" } })
const recycled = yield* Queue.unbounded<string>()
yield* pty.connect(info.id, first.socket)
first.socket.data = { events: { connection: "b" } }
first.socket.send = (data) => Queue.offerUnsafe(recycled, decodeOutput(data))
yield* pty.write(info.id, "AAA\n")
const verify = yield* makeSocket({ events: { connection: "verify" } })
yield* pty.connect(info.id, verify.socket)
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
expect(yield* waitForOutput(recycled, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
}),
)
ptyTest("treats in-place socket data mutation as the same connection", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
const data = { connId: 1 }
const socket = yield* makeSocket(data)
yield* pty.connect(info.id, socket.socket)
data.connId = 2
yield* pty.write(info.id, "AAA\n")
expect(yield* waitForOutput(socket.output, "AAA")).toContain("AAA")
}),
)
})
+158 -10
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer, Queue } from "effect"
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
import { Config } from "@opencode-ai/core/config"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
@@ -14,7 +15,14 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const it = testEffect(
Pty.layer.pipe(
Layer.provide(configLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
@@ -56,36 +64,176 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
}),
)
const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: PtyID, cursor?: number) {
const pty = yield* Pty.Service
const output = yield* Queue.unbounded<string>()
const ended = yield* Deferred.make<{ exitCode?: number }>()
const attachment = yield* pty.attach(id, {
cursor,
onData: (chunk) => Queue.offerUnsafe(output, chunk),
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
})
attachment.activate()
return { attachment, output, ended }
})
const waitForOutput = (output: Queue.Queue<string>, text: string) =>
Effect.gen(function* () {
let received = ""
while (!received.includes(text)) received += yield* Queue.take(output)
return received
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
describe("pty", () => {
it.live("returns typed not found errors for missing sessions", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = "pty_missing" as PtyID
let closed = false
const socket = { readyState: 1, send: () => {}, close: () => void (closed = true) }
for (const result of [
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit),
yield* pty.remove(id).pipe(Effect.exit),
yield* pty.resize(id, 80, 24).pipe(Effect.exit),
yield* pty.write(id, "input").pipe(Effect.exit),
yield* pty.connect(id, socket).pipe(Effect.asVoid, Effect.exit),
yield* pty.attach(id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.asVoid, Effect.exit),
]) {
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result))
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
}
expect(closed).toBe(true)
}),
)
ptyTest("publishes created, exited, deleted in order for a short-lived process", () =>
ptyTest("retains exited sessions until removed", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const events = yield* subscribePtyEvents()
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "sleep 0.1"])
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "exit 3"])
expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"])
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"])
const exited = yield* pty.get(info.id)
expect(exited.status).toBe("exited")
expect(exited.exitCode).toBe(3)
yield* pty.remove(info.id)
expect(yield* waitForEvents(events, info.id, 1)).toEqual(["deleted"])
const missing = yield* pty.get(info.id).pipe(Effect.exit)
expect(Exit.isFailure(missing)).toBe(true)
}),
)
ptyTest("replays buffered output and streams live output to attachments", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
yield* pty.write(info.id, "AAA\n")
const first = yield* attachCollecting(info.id)
expect(yield* waitForOutput(first.output, "AAA")).toContain("AAA")
first.attachment.write("BBB\n")
yield* waitForOutput(first.output, "BBB")
// A later attachment replays everything already buffered.
const replayed = yield* attachCollecting(info.id)
expect(replayed.attachment.replay).toContain("AAA")
expect(replayed.attachment.replay).toContain("BBB")
expect(replayed.attachment.cursor).toBeGreaterThan(0)
// Tail attachments skip the buffer and only see subsequent output.
const tail = yield* attachCollecting(info.id, -1)
expect(tail.attachment.replay).toBe("")
expect(tail.attachment.cursor).toBe(replayed.attachment.cursor)
}),
)
ptyTest("stops delivering output after detach", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
const attached = yield* attachCollecting(info.id, -1)
attached.attachment.detach()
yield* pty.write(info.id, "AAA\n")
const verify = yield* attachCollecting(info.id)
yield* waitForOutput(verify.output, "AAA")
const leaked = yield* Queue.poll(attached.output)
expect(leaked._tag).toBe("None")
}),
)
ptyTest("isolates output between sessions", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty("cat")
const b = yield* createPty("cat")
const attachedA = yield* attachCollecting(a.id)
const attachedB = yield* attachCollecting(b.id)
yield* pty.write(a.id, "AAA\n")
yield* waitForOutput(attachedA.output, "AAA")
const leaked = yield* Queue.poll(attachedB.output)
expect(leaked._tag).toBe("None")
}),
)
ptyTest("notifies attachments with the exit code and rejects attach after exit", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const events = yield* subscribePtyEvents()
const info = yield* createPty("cat")
const attached = yield* attachCollecting(info.id)
yield* pty.write(info.id, "\u0004")
expect(yield* Deferred.await(attached.ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 0 })
yield* waitForEvents(events, info.id, 2)
const result = yield* pty.attach(info.id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result))
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id })
}),
)
})
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect(
Pty.layer.pipe(
Layer.provide(
Layer.mock(Config.Service)({
entries: () =>
Effect.succeed(
configuredShell
? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
: [],
),
}),
),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
describe("pty create defaults", () => {
configuredTest("defaults command, login args, and cwd from config and location", () =>
Effect.gen(function* () {
if (!configuredShell) return
const pty = yield* Pty.Service
const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) =>
pty.remove(created.id).pipe(Effect.ignore),
)
expect(info.command).toBe(configuredShell)
expect(info.args).toEqual(["-l"])
expect(info.cwd).toBe("/tmp")
expect(info.title).toBe("configured")
}),
)
})
@@ -3,6 +3,8 @@ import { LLM } from "@opencode-ai/llm"
import { LLMClient } from "@opencode-ai/llm/route"
import { ConfigProvider, DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -45,8 +47,6 @@ const provider = (api: ProviderV2.Info["api"]) =>
new ProviderV2.Info({
id: ProviderV2.ID.make("test-provider"),
name: "Test provider",
enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
env: ["TEST_PROVIDER_API_KEY"],
api,
request: { headers: {}, body: {} },
})
@@ -247,7 +247,7 @@ describe("SessionRunnerModel", () => {
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {}, generation: {}, options: {} },
}),
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
{ type: "env", name: "TEST_PROVIDER_API_KEY" },
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth
@@ -266,6 +266,35 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () {
const credential = new Credential.Stored({
id: Credential.ID.create(),
integrationID: Integration.ID.make("test-provider"),
label: "Work",
value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }),
})
const resolved = yield* SessionRunnerModel.fromCatalogModel(
new ModelV2.Info({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} },
}),
{ type: "credential", id: credential.id, label: credential.label },
credential,
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer stored-secret")
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
}),
)
it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
+56 -58
View File
@@ -704,7 +704,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
location: { directory: AbsolutePath.make("/moved") },
})
expect(
yield* db
@@ -762,7 +762,7 @@ describe("SessionRunnerLLM", () => {
.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
location: { directory: AbsolutePath.make("/moved") },
})
.pipe(Effect.asVoid)
})
@@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => {
})
requests.length = 0
executions.length = 0
responses = [
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
@@ -3177,7 +3178,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("fails after the bounded number of local tool continuation steps", () =>
it.effect("continues past 25 local tool steps when the agent has no step limit", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => {
executions.length = 0
streamGate = undefined
streamStarted = undefined
responses = Array.from({ length: 25 }, (_, index) => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
])
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 })
expect(requests).toHaveLength(25)
expect(executions).toHaveLength(25)
}),
)
it.effect("does not restart a capped tool loop for a coalesced stale wake", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const coordinator = yield* SessionRunCoordinator.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false })
requests.length = 0
responses = Array.from({ length: 25 }, (_, index) => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
])
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* coordinator.wake(sessionID)
yield* Deferred.succeed(streamGate, undefined)
expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" })
streamGate = undefined
streamStarted = undefined
yield* Effect.yieldNow
expect(requests).toHaveLength(25)
}),
)
it.effect("accepts a terminal response on the final bounded provider turn", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
requests.length = 0
responses = [
...Array.from({ length: 24 }, (_, index) => [
...Array.from({ length: 25 }, (_, index) => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }),
LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
]),
@@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests).toHaveLength(25)
expect(requests).toHaveLength(26)
expect(executions).toHaveLength(25)
}),
)
it.effect("forces a text response on an agent's configured final step", () =>
Effect.gen(function* () {
yield* setup
const agents = yield* AgentV2.Service
yield* agents.update((editor) =>
editor.update(AgentV2.ID.make("build"), (agent) => {
agent.steps = 2
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
requests.length = 0
executions.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(requests[0]?.toolChoice).toBeUndefined()
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
expect(requests[1]?.tools).toEqual([])
expect(requests[1]?.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
})
expect(executions).toEqual(["done"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Finish at the limit" },
{ type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] },
{ type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] },
])
}),
)
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Shell } from "../../src/shell/shell"
import { Filesystem } from "@/util/filesystem"
import { Shell } from "@opencode-ai/core/shell"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { which } from "@opencode-ai/core/util/which"
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
@@ -54,6 +54,15 @@ describe("shell", () => {
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
})
test("builds command args per shell family", () => {
expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
const zsh = Shell.args("/bin/zsh", "echo hi", "/tmp")
expect(zsh[0]).toBe("-l")
expect(zsh[1]).toBe("-c")
expect(zsh.at(-1)).toBe("/tmp")
})
if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => {
@@ -64,7 +73,7 @@ describe("shell", () => {
test("normalizes Git Bash shell paths from env", async () => {
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
await withShell(shell, async () => {
expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell))
expect(Shell.preferred()).toBe(FSUtil.windowsPath(shell))
})
})
+2 -2
View File
@@ -7,8 +7,8 @@
"private": true,
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"exports": {
".": "./src/index.ts",
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+4 -4
View File
@@ -10,8 +10,8 @@
"url": "git+https://github.com/Kilo-Org/kilocode.git",
"directory": "packages/http-recorder"
},
"homepage": "https://github.com/Kilo-Org/kilocode/tree/main/packages/http-recorder",
"bugs": "https://github.com/Kilo-Org/kilocode/issues",
"homepage": "https://github.com/anomalyco/opencode/tree/dev/packages/http-recorder",
"bugs": "https://github.com/anomalyco/opencode/issues",
"keywords": [
"effect",
"http",
@@ -28,10 +28,10 @@
},
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit",
"build": "bun ./script/build.ts",
"verify:package": "bun ./script/verify-package.ts"
"verify:package": "bun ./script/verify-package.ts",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"exports": {
".": "./src/index.ts",
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+2 -2
View File
@@ -8,8 +8,8 @@
"scripts": {
"setup:recording-env": "bun run script/setup-recording-env.ts",
"test": "bun test --timeout 30000 --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"exports": {
".": "./src/index.ts",
@@ -412,6 +412,9 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
stopSequences: generation?.stop,
},
toolConfig,
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
// as a model-specific field, so it goes through additionalModelRequestFields.
additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK },
}
})
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
@@ -83,6 +83,26 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }),
)
// Converse's inferenceConfig has no topK; Anthropic/Nova read it from
// additionalModelRequestFields as top_k.
expect(prepared.body.inferenceConfig).toEqual({ maxTokens: 64, temperature: 0 })
expect(prepared.body.additionalModelRequestFields).toEqual({ top_k: 40 })
}),
)
it.effect("omits additionalModelRequestFields when topK is unset", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(baseRequest)
expect(prepared.body.additionalModelRequestFields).toBeUndefined()
}),
)
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
+13 -7
View File
@@ -80,10 +80,11 @@ export class Subscription {
async replayMessage(message: SessionMessageResponse) {
if (message.info.role !== "assistant" && message.info.role !== "user") return
const cwd = message.info.role === "assistant" ? message.info.path?.cwd : undefined
for (const part of message.parts) {
await this.recordFetchedPart(message.info.sessionID, message, part)
if (part.type === "tool") {
await this.handleToolPart(message.info.sessionID, part)
await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd())
continue
}
await this.replayContentPart(message, part)
@@ -146,7 +147,7 @@ export class Subscription {
}),
)
if (part.type === "tool") {
await this.handleToolPart(session.id, part)
await this.handleToolPart(session.id, part, session.cwd)
}
}
@@ -231,8 +232,8 @@ export class Subscription {
)
}
private async handleToolPart(sessionId: string, part: ToolPart) {
await this.toolStart(sessionId, part)
private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) {
await this.toolStart(sessionId, part, cwd)
switch (part.state.status) {
case "pending":
@@ -240,7 +241,7 @@ export class Subscription {
return
case "running":
await this.runningTool(sessionId, part)
await this.runningTool(sessionId, part, cwd)
return
case "completed":
@@ -253,6 +254,7 @@ export class Subscription {
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
cwd,
}),
},
})
@@ -268,6 +270,7 @@ export class Subscription {
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
cwd,
}),
},
})
@@ -275,7 +278,7 @@ export class Subscription {
}
}
private async runningTool(sessionId: string, part: ToolPart) {
private async runningTool(sessionId: string, part: ToolPart, cwd: string) {
if (part.state.status !== "running") return
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
@@ -289,6 +292,7 @@ export class Subscription {
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
cwd,
}),
},
})
@@ -306,12 +310,13 @@ export class Subscription {
toolName: part.tool,
state: part.state,
output,
cwd,
}),
},
})
}
private async toolStart(sessionId: string, part: ToolPart) {
private async toolStart(sessionId: string, part: ToolPart, cwd: string) {
if (this.toolStarts.has(part.callID)) return
this.toolStarts.add(part.callID)
await this.input.connection.sessionUpdate({
@@ -322,6 +327,7 @@ export class Subscription {
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
cwd,
}),
},
})
+65 -19
View File
@@ -1,3 +1,4 @@
import { isAbsolute, resolve } from "path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
@@ -69,10 +70,16 @@ export function toToolKind(toolName: string): ToolKind {
}
}
export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "bash":
case "shell": {
const workdir = shellWorkdir(input, cwd)
return workdir ? [{ path: workdir }] : []
}
case "read":
case "edit":
case "write":
@@ -88,10 +95,6 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
case "context7_get_library_docs":
return locationFrom(input.path)
case "bash":
case "shell":
return []
default:
return []
}
@@ -122,14 +125,15 @@ export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly cwd?: string
}): ToolCall {
return {
toolCallId: input.toolCallId,
title: input.state.title || input.toolName,
title: toolTitle(input.toolName, input.state.input, input.state.title),
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}
@@ -138,6 +142,7 @@ export function runningToolUpdate(input: {
readonly toolName: string
readonly state: RunningToolState
readonly output?: string
readonly cwd?: string
}): ToolCallUpdate {
const content = input.output
? [
@@ -155,9 +160,9 @@ export function runningToolUpdate(input: {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
...(content ? { content } : {}),
}
}
@@ -166,29 +171,32 @@ export function duplicateRunningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: RunningToolState
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: CompletedToolState & { readonly title: string }
readonly state: CompletedToolState & { readonly title?: string }
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "completed",
kind: toToolKind(input.toolName),
title: input.state.title,
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
content: completedToolContent(input.toolName, input.state),
rawInput: input.state.input,
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
rawOutput: completedToolRawOutput(input.state),
}
}
@@ -197,13 +205,15 @@ export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: ErrorToolState
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: input.toolName,
rawInput: input.state.input,
title: toolTitle(input.toolName, input.state.input, undefined),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
content: [
{
type: "content",
@@ -253,6 +263,42 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
return stringValue((state.metadata as Record<string, unknown>).output)
}
// For shell tools, surface the actual command as the title so it stays visible
// before output lands; non-shell tools keep their model-provided title.
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName
return fallback || toolName
}
// Enrich shell rawInput with the resolved working directory so clients can show
// where the command runs, unless the model already specified one.
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
if (!isShell(toolName)) return input
if (input.cwd || input.workdir) return input
const workdir = shellWorkdir(input, cwd)
return workdir ? { ...input, cwd: workdir } : input
}
function shellWorkdir(input: ToolInput, cwd?: string) {
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
return resolvePath(explicit, cwd) ?? cwd
}
function resolvePath(value: string | undefined, cwd?: string) {
if (!value) return undefined
if (isAbsolute(value)) return value
return resolve(cwd ?? process.cwd(), value)
}
function shellCommand(input: ToolInput) {
return stringValue(input.command) ?? stringValue(input.cmd)
}
function isShell(toolName: string) {
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}
export const mapToolKind = toToolKind
export const extractLocations = toLocations
export const buildCompletedToolContent = completedToolContent
+2 -1
View File
@@ -5,6 +5,7 @@ import { Cause } from "effect"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { MCP } from "../../mcp"
@@ -770,7 +771,7 @@ export const McpDebugCommand = effectCmd({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "kilo-debug", version: InstallationVersion }, // kilocode_change
},
@@ -6,7 +6,7 @@ import { Identifier } from "@/id/id"
import { Instance, type InstanceContext } from "@/kilocode/instance"
import { KiloShutdown } from "@/kilocode/cli/shutdown"
import { SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Process } from "@/util/process"
import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
@@ -1,5 +1,5 @@
import { Process } from "@/util/process"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Effect, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
@@ -6,7 +6,7 @@ import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output"
import { Identifier } from "@/id/id"
import { Instance, type InstanceContext } from "@/kilocode/instance"
import { SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import * as Log from "@opencode-ai/core/util/log"
@@ -1,61 +1 @@
import path from "path"
type Input = {
command?: string
args?: string[]
cwd?: string
}
type Command = {
command: string
args: string[]
cwd?: string
}
const names = new Set(["kilo", "kilocode"])
const self = command()
function clean(input: string[]) {
return input.filter((arg, index) => {
if (arg === "--cwd") return false
if (input[index - 1] === "--cwd") return false
if (arg.startsWith("--cwd=")) return false
return true
})
}
function full(input: string, cwd: string) {
if (path.isAbsolute(input)) return input
return path.resolve(cwd, input)
}
export function command(
proc = { argv: process.argv, execArgv: process.execArgv, execPath: process.execPath, cwd: process.cwd() },
): Command {
const script = proc.argv[1]
const bundled = script?.startsWith("/$bunfs/") || (script ? /^[A-Za-z]:[\\/]~BUN[\\/]/.test(script) : false)
if (script && !bundled && /\.(ts|js|mjs|cjs)$/.test(script)) {
const file = full(script, proc.cwd)
const dir = path.dirname(file)
const root = path.basename(dir) === "src" ? path.dirname(dir) : proc.cwd
return { command: full(proc.execPath, proc.cwd), args: [...clean(proc.execArgv), file], cwd: root }
}
return { command: full(proc.execPath, proc.cwd), args: [] }
}
export function resolve(input: Input, cmd = self): Input {
if (!input.command || !names.has(input.command)) return input
const args = input.args ?? []
const project = cmd.cwd && args.length === 0 && input.cwd ? [input.cwd] : []
return {
...input,
command: cmd.command,
args: [...cmd.args, ...project, ...args],
cwd: cmd.cwd ?? input.cwd,
}
}
export const KiloPtySelfCommand = {
command,
resolve,
}
export { command, KiloPtySelfCommand, resolve } from "@opencode-ai/core/kilocode/pty-self-command"
@@ -2,13 +2,5 @@
// Kilo-specific overrides for the server control plane.
// Imported by ../../server/server.ts with minimal kilocode_change markers.
/** Additional CORS origin check for *.kilo.ai */
export function corsOrigin(input: string): string | undefined {
if (/^https:\/\/([a-z0-9-]+\.)*kilo\.ai$/.test(input)) {
return input
}
return undefined
}
export const DOC_TITLE = "kilo"
export const DOC_DESCRIPTION = "kilo api"
+1 -124
View File
@@ -1,124 +1 @@
export function args(command: string) {
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)]
}
const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false);
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);
$OutputEncoding = [Console]::OutputEncoding;
`
function script(command: string) {
const pos = prologue(command)
const head = command.slice(0, pos)
const body = command.slice(pos)
const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : ""
return `${head}${gap}${setup}${body}`
}
function prologue(command: string) {
const pos = scan(command, 0)
const attr = attrs(command, pos)
const body = command.slice(attr)
const match = /^param\s*\(/i.exec(body)
if (!match) return pos
const start = attr + match[0].lastIndexOf("(")
const end = block(command, start, "(", ")")
if (end === undefined) return pos
return end
}
function attrs(command: string, start: number) {
let pos = start
while (pos < command.length) {
const next = scan(command, pos)
if (command[next] !== "[") return next
const end = block(command, next, "[", "]")
if (end === undefined) return start
pos = end
}
return pos
}
function scan(command: string, start: number) {
let pos = start
while (pos < command.length) {
const next = trivia(command, pos)
if (next !== pos) {
pos = next
continue
}
const end = line(command, pos)
const value = command.slice(pos, end)
if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) {
pos = end
continue
}
return pos
}
return pos
}
function trivia(command: string, start: number) {
let pos = start
while (pos < command.length) {
while (/\s/.test(command[pos] ?? "")) pos++
if (command[pos] === "#") {
pos = line(command, pos)
continue
}
if (command.startsWith("<#", pos)) {
const end = command.indexOf("#>", pos + 2)
if (end === -1) return command.length
pos = end + 2
continue
}
return pos
}
return pos
}
function line(command: string, start: number) {
const index = command.indexOf("\n", start)
if (index === -1) return command.length
return index + 1
}
function block(command: string, start: number, open: string, close: string) {
let depth = 0
let quote: string | undefined
for (let pos = start; pos < command.length; pos++) {
const char = command[pos]
if (quote) {
if (quote === "'" && char === "'" && command[pos + 1] === "'") {
pos++
continue
}
if (quote === '"' && char === "`") {
pos++
continue
}
if (char === quote) quote = undefined
continue
}
if (char === "'" || char === '"') {
quote = char
continue
}
if (command.startsWith("<#", pos)) {
const end = command.indexOf("#>", pos + 2)
if (end === -1) return
pos = end + 1
continue
}
if (char === "#") {
pos = line(command, pos) - 1
continue
}
if (char === open) depth++
if (char === close) {
depth--
if (depth === 0) return pos + 1
}
}
}
export { args, PowerShell } from "@opencode-ai/core/kilocode/powershell"
@@ -2,7 +2,7 @@ import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { InteractiveTerminal } from "@/kilocode/interactive-terminal"
import { Plugin } from "@/plugin"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { ShellPermission } from "@/tool/shell"
import { Tool } from "@/tool/tool"
import type { FSUtil } from "@opencode-ai/core/fs-util"
+9
View File
@@ -61,8 +61,17 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
resetTimeoutOnProgress: true,
signal: options.abortSignal,
timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (result.isError)
throw new Error(
result.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
if (result.structuredContent === undefined || result.structuredContent === null) return result
return {
...result,
+30 -5
View File
@@ -8,16 +8,18 @@ if (process.platform === "win32" && !("type" in process)) {
// kilocode_change end
import path from "node:path"
import { pathToFileURL } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { type Tool } from "ai"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import {
ListRootsRequestSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
type Tool as MCPToolDef,
@@ -45,6 +47,18 @@ import * as SandboxNetwork from "@/kilocode/sandbox/network" // kilocode_change
import { McpCatalog } from "./catalog"
const DEFAULT_TIMEOUT = 30_000
const CLIENT_OPTIONS = {
capabilities: {
// https://github.com/anomalyco/opencode/issues/11948
// sampling: {},
// https://github.com/anomalyco/opencode/issues/23066
// elicitation: {},
// https://github.com/anomalyco/opencode/issues/2308
roots: {},
// https://github.com/anomalyco/opencode/issues/28567
// tasks: {},
},
} satisfies ClientOptions
// kilocode_change start - inject --rm for Docker containers to prevent stopped container accumulation
export function ensureDockerRm(cmd: string, args: string[]): string[] {
@@ -94,6 +108,14 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP
type MCPClient = Client
function createClient(directory: string) {
const client = new Client({ name: "kilo", version: InstallationVersion }, CLIENT_OPTIONS) // kilocode_change
client.setRequestHandler(ListRootsRequestSchema, () =>
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
)
return client
}
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
identifier: "MCPStatusConnected",
})
@@ -204,19 +226,21 @@ export const layer = Layer.effect(
* Connect a client via the given transport with resource safety:
* on failure the transport is closed; on success the caller owns it.
*/
const connectTransport = (transport: Transport, timeout: number) =>
Effect.acquireUseRelease(
const connectTransport = Effect.fn("MCP.connectTransport")(function* (transport: Transport, timeout: number) {
const directory = yield* InstanceState.directory
return yield* Effect.acquireUseRelease(
Effect.succeed(transport),
(t) =>
Effect.tryPromise({
try: () => {
const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change
const client = createClient(directory)
return withTimeout(client.connect(t), timeout).then(() => client)
},
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
}),
(t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void),
)
})
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
@@ -806,10 +830,11 @@ export const layer = Layer.effect(
authProvider,
requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined,
})
const directory = yield* InstanceState.directory
return yield* Effect.tryPromise({
try: () => {
const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change
const client = createClient(directory)
return client
.connect(transport)
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
+18 -6
View File
@@ -1,5 +1,6 @@
import { createConnection } from "net"
import { createServer } from "http"
import { escapeHtml } from "@/util/html"
import * as Log from "@opencode-ai/core/util/log" // kilocode_change
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
import * as KiloOAuthCallback from "../kilocode/mcp-oauth-callback" // kilocode_change
@@ -52,7 +53,7 @@ const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
@@ -80,6 +81,13 @@ function cleanupStateIndex(oauthState: string) {
}
}
function stopIfIdle() {
if (pendingAuths.size > 0 || !server) return
server.close()
server = undefined
}
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
@@ -97,7 +105,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html" })
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg))
return
}
@@ -111,13 +119,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
cleanupStateIndex(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg))
stopIfIdle()
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
@@ -125,7 +134,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html" })
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg))
return
}
@@ -137,8 +146,9 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
cleanupStateIndex(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_SUCCESS)
stopIfIdle()
}
export async function ensureRunning(redirectUri?: string): Promise<void> {
@@ -212,6 +222,7 @@ export function waitForCallback(oauthState: string, mcpName?: string): Promise<s
pendingAuths.delete(oauthState)
if (mcpName) mcpNameToState.delete(mcpName)
reject(new Error("OAuth callback timeout - authorization took too long"))
stopIfIdle()
}
}, CALLBACK_TIMEOUT_MS)
@@ -229,6 +240,7 @@ export function cancelPending(mcpName: string): void {
pendingAuths.delete(key)
mcpNameToState.delete(mcpName)
pending.reject(new Error("Authorization cancelled"))
stopIfIdle()
}
}
@@ -70,6 +70,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
return CopilotModels.get(
base(auth.enterpriseUrl),
{
...(provider.options?.headers as Record<string, string> | undefined),
Authorization: `Bearer ${auth.refresh}`,
"User-Agent": `opencode/${InstallationVersion}`,
"X-GitHub-Api-Version": API_VERSION,
+3 -2
View File
@@ -148,11 +148,12 @@ export const layer = Layer.effect(
const { Server } = yield* Effect.promise(() => import("../server/server"))
const serverUrl = Server.url
const client = createKiloClient({
baseUrl: "http://localhost:4096",
baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
directory: ctx.directory,
headers: ServerAuth.headers(),
fetch: async (...args) => Server.Default().app.fetch(...args),
...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }),
})
const cfg = yield* config.get()
const input: PluginInput = {
+10 -9
View File
@@ -1,5 +1,6 @@
import type { Hooks, PluginInput } from "@kilocode/plugin"
import * as Log from "@opencode-ai/core/util/log" // kilocode_change
import { escapeHtml } from "@/util/html"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OAUTH_DUMMY_KEY } from "../../auth"
import os from "os"
@@ -203,7 +204,7 @@ const HTML_SUCCESS = `<!doctype html>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!doctype html>
export const renderOAuthError = (error: string) => `<!doctype html>
<html>
<head>
<!-- kilocode_change start -->
@@ -248,7 +249,7 @@ const HTML_ERROR = (error: string) => `<!doctype html>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
@@ -281,8 +282,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(renderOAuthError(errorMsg))
return
}
@@ -290,8 +291,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(renderOAuthError(errorMsg))
return
}
@@ -299,8 +300,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(renderOAuthError(errorMsg))
return
}
@@ -311,7 +312,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_SUCCESS)
return
}
@@ -0,0 +1,24 @@
export * as PluginPtyEnvironment from "./pty-environment"
import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
import { Effect, Layer } from "effect"
import { InstanceStore } from "@/project/instance-store"
import { Plugin } from "."
export const layer = Layer.effect(
PtyEnvironment.Service,
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const instances = yield* InstanceStore.Service
return PtyEnvironment.Service.of({
get: Effect.fn("PtyEnvironment.get")(function* (input) {
return yield* instances.provide(
{ directory: input.directory },
plugin
.trigger("shell.env", { cwd: input.cwd }, { env: {} as Record<string, string> })
.pipe(Effect.map((result) => result.env)),
)
}),
})
}),
)
+1 -19
View File
@@ -2,6 +2,7 @@ import type { Hooks, PluginInput } from "@kilocode/plugin"
import { OAUTH_DUMMY_KEY } from "../auth"
import { createServer } from "http"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { escapeHtml } from "@/util/html"
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
@@ -74,25 +75,6 @@ function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
}
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
switch (char) {
case "&":
return "&amp;"
case "<":
return "&lt;"
case ">":
return "&gt;"
case '"':
return "&quot;"
case "'":
return "&#39;"
default:
return char
}
})
}
interface TokenResponse {
access_token: string
refresh_token: string
+1 -1
View File
@@ -844,7 +844,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
apiKey: apiToken,
...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}),
})
const unified = createUnified()
const unified = createUnified({ apiKey: apiToken })
return {
autoload: true,
+96 -2
View File
@@ -226,7 +226,7 @@ function normalizeMessages(
if (
model.providerID === "mistral" ||
model.api.id.toLowerCase().includes("mistral") ||
model.api.id.toLocaleLowerCase().includes("devstral")
model.api.id.toLowerCase().includes("devstral")
) {
const scrub = (id: string) => {
return id
@@ -730,7 +730,6 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
max: { effort: "max" },
}
}
if (
id.includes("deepseek-chat") ||
id.includes("deepseek-reasoner") ||
@@ -1425,6 +1424,96 @@ export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_T
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
}
type JsonRecord = Record<string, unknown>
function isPlainObject(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
// Mirrors Codex's Rust JSON schema compatibility lowering for OpenAI tool schemas.
function sanitizeOpenAISchema(value: unknown): unknown {
const types = ["string", "number", "boolean", "integer", "object", "array", "null"]
const compositionKeys = ["anyOf", "oneOf", "allOf"]
// JSON Schema's boolean form (`true`/`false`) is unsupported by OpenAI tool schemas.
if (typeof value === "boolean") return { type: "string" }
if (Array.isArray(value)) return value.map(sanitizeOpenAISchema)
if (!isPlainObject(value)) return value
const result: JsonRecord = {}
if (typeof value.$ref === "string") result.$ref = value.$ref
if (typeof value.description === "string") result.description = value.description
if ("const" in value) result.enum = [value.const]
else if (Array.isArray(value.enum)) result.enum = value.enum
if (isPlainObject(value.properties)) {
result.properties = Object.fromEntries(
Object.entries(value.properties).map(([key, item]) => [key, sanitizeOpenAISchema(item)]),
)
}
if (Array.isArray(value.required)) {
result.required = value.required.filter((item) => typeof item === "string")
}
if ("items" in value) result.items = sanitizeOpenAISchema(value.items)
if ("additionalProperties" in value) {
result.additionalProperties =
typeof value.additionalProperties === "boolean"
? value.additionalProperties
: sanitizeOpenAISchema(value.additionalProperties)
}
for (const key of compositionKeys) {
if (Array.isArray(value[key])) result[key] = value[key].map(sanitizeOpenAISchema)
}
for (const key of ["$defs", "definitions"]) {
if (isPlainObject(value[key])) {
result[key] = Object.fromEntries(
Object.entries(value[key]).map(([name, item]) => [name, sanitizeOpenAISchema(item)]),
)
}
}
const schemaTypes =
typeof value.type === "string"
? types.includes(value.type)
? [value.type]
: []
: Array.isArray(value.type)
? value.type.filter((item) => typeof item === "string" && types.includes(item))
: []
if (schemaTypes.length === 0 && (typeof result.$ref === "string" || compositionKeys.some((key) => key in result))) {
return result
}
// MCP schemas may omit `type` while still using keywords that imply one.
// Keep the schema usable after unsupported keywords are dropped.
const inferredTypes =
schemaTypes.length > 0
? schemaTypes
: ["properties", "required", "additionalProperties"].some((key) => key in value)
? ["object"]
: ["items", "prefixItems"].some((key) => key in value)
? ["array"]
: "enum" in result || "format" in value
? ["string"]
: ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in value)
? ["number"]
: []
if (inferredTypes.length === 0) return {}
result.type = inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes
if (inferredTypes.includes("object") && !("properties" in result)) result.properties = {}
if (inferredTypes.includes("array") && !("items" in result)) result.items = { type: "string" }
return result
}
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
/*
if (["openai", "azure"].includes(providerID)) {
@@ -1444,6 +1533,11 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
}
*/
if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") {
schema = sanitizeOpenAISchema(schema) as JSONSchema7
// Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget.
}
if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
const sanitizeMoonshot = (obj: unknown): unknown => {
if (obj === null || typeof obj !== "object") return obj
-46
View File
@@ -1,46 +0,0 @@
export * as PtyPreparation from "./pty-preparation"
import { Config } from "@/config/config"
import * as InstanceState from "@/effect/instance-state"
import { Plugin } from "@/plugin"
import { Shell } from "@/shell/shell"
import { Pty } from "@opencode-ai/core/pty"
import { KiloPtySelfCommand } from "@/kilocode/pty/self-command" // kilocode_change - ported from the deleted @/pty module
import { Effect } from "effect"
export const prepareCreate = Effect.fn("PtyPreparation.prepareCreate")(function* (input: Pty.CreateInput) {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
// kilocode_change start - resolve Kilo self-commands (e.g. bare `kilo`) to the real binary + args + project cwd
const resolved = KiloPtySelfCommand.resolve({
command: input.command,
args: input.args ? [...input.args] : undefined,
cwd: input.cwd,
})
const command = resolved.command || Shell.preferred((yield* config.get()).shell)
const baseArgs = resolved.args ?? []
const cwd = resolved.cwd || (yield* InstanceState.context).directory
// kilocode_change end
const args = Shell.login(command) ? [...baseArgs, "-l"] : [...baseArgs]
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shell.env,
TERM: "xterm-256color",
KILO_TERMINAL: "1",
} as Record<string, string>
// kilocode_change start - ported from the deleted @/pty module.
// Don't leak the kilo server's auth credential into user shells: anything the shell forks (npm
// post-install, `curl | bash`, compromised tools) would otherwise see the password for free. Users
// who need `kilo run`/`kilo tui attach` to auto-connect from a kilo-spawned terminal pass --password.
delete env.KILO_SERVER_PASSWORD
delete env.KILO_SERVER_USERNAME
// kilocode_change end
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
return { command, args, cwd, title: input.title, env }
})
@@ -27,6 +27,10 @@ const ConsoleStateResponse = Schema.Struct({
switchableOrgCount: NonNegativeInt,
}).annotate({ identifier: "ConsoleState" })
const CapabilitiesResponse = Schema.Struct({
backgroundSubagents: Schema.Boolean,
}).annotate({ identifier: "ExperimentalCapabilities" })
const ConsoleOrgOption = Schema.Struct({
accountID: Schema.String,
accountEmail: Schema.String,
@@ -106,6 +110,7 @@ export const WorktreeDiffFileQuery = Schema.Struct({
// kilocode_change end
export const ExperimentalPaths = {
capabilities: "/experimental/capabilities",
console: "/experimental/console",
consoleOrgs: "/experimental/console/orgs",
consoleSwitch: "/experimental/console/switch",
@@ -125,6 +130,16 @@ export const ExperimentalApi = HttpApi.make("experimental")
.add(
HttpApiGroup.make("experimental")
.add(
HttpApiEndpoint.get("capabilities", ExperimentalPaths.capabilities, {
query: WorkspaceRoutingQuery,
success: described(CapabilitiesResponse, "Experimental capabilities"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.capabilities.get",
summary: "Get experimental capabilities",
description: "Get experimental features enabled on the OpenCode server.",
}),
),
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
query: WorkspaceRoutingQuery,
success: described(ConsoleStateResponse, "Active Console provider metadata"),
@@ -53,6 +53,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const background = yield* BackgroundJob.Service
const flags = yield* RuntimeFlags.Service
const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () {
return { backgroundSubagents: flags.experimentalBackgroundSubagents }
})
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[
@@ -270,25 +274,24 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
return yield* mcp.resources()
})
return (
handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
// kilocode_change start
.handle("worktreeDiff", worktreeDiff)
.handle("worktreeDiffSummary", worktreeDiffSummary)
.handle("worktreeDiffFile", worktreeDiffFile)
// kilocode_change end
.handle("session", session)
.handle("sessionBackground", sessionBackground)
.handle("resource", resource)
)
return handlers
.handle("capabilities", capabilities)
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
// kilocode_change start
.handle("worktreeDiff", worktreeDiff)
.handle("worktreeDiffSummary", worktreeDiffSummary)
.handle("worktreeDiffFile", worktreeDiffFile)
// kilocode_change end
.handle("session", session)
.handle("sessionBackground", sessionBackground)
.handle("resource", resource)
}),
)
@@ -1,23 +1,22 @@
import * as InstanceState from "@/effect/instance-state"
import { registerDisposer } from "@/effect/instance-registry"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { PtyPreparation } from "@/pty-preparation"
import { Plugin } from "@/plugin"
import { Pty } from "@opencode-ai/core/pty"
import { handlePtyInput } from "@opencode-ai/core/pty/input"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Shell } from "@/shell/shell"
import { EffectBridge } from "@/effect/bridge"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
import { Shell } from "@opencode-ai/core/shell"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@opencode-ai/server/cors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { Effect, Layer, Option, Schema } from "effect"
import { Effect, Layer, Option, Queue, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
@@ -36,10 +35,14 @@ const ticketScope = Effect.gen(function* () {
return { directory: instance?.directory, workspaceID }
})
// Legacy surface compatibility: before exited-session retention, sessions vanished the moment
// their process exited. These routes preserve that observable behavior — exited sessions are
// invisible here — while the canonical /api/pty surface exposes them until removal.
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const plugin = yield* Plugin.Service
const locations = yield* LocationServiceMap
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
@@ -59,33 +62,42 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty(Pty.Service.use((service) => service.list()))
const sessions = yield* pty(Pty.Service.use((service) => service.list()))
return sessions.filter((info) => info.status === "running")
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const cwd = ctx.payload.cwd || (yield* InstanceState.context).directory
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} as Record<string, string> })
return yield* pty(
Pty.Service.use((service) =>
Effect.flatMap(
PtyPreparation.prepareCreate({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
service.create,
),
service.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
cwd,
env: { ...ctx.payload.env, ...shell.env },
}),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
return yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
Effect.catchTag(
"Pty.NotFoundError",
(error) =>
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
),
Effect.flatMap((info) =>
info.status === "running"
? Effect.succeed(info)
: new ApiError.PtyNotFoundError({
ptyID: ctx.params.ptyID,
message: `PTY session not found: ${ctx.params.ptyID}`,
}),
),
)
})
@@ -94,6 +106,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
yield* get(ctx)
return yield* pty(
Pty.Service.use((service) =>
service.update(ctx.params.ptyID, {
@@ -102,26 +115,27 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
}),
),
).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
Effect.catchTag(
"Pty.NotFoundError",
(error) =>
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
),
)
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* get(ctx)
yield* pty(Pty.Service.use((service) => service.remove(ctx.params.ptyID))).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
Effect.catchTag(
"Pty.NotFoundError",
(error) =>
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
),
)
return true
@@ -131,16 +145,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const request = yield* HttpServerRequest.HttpServerRequest
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
return yield* new ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" })
yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
),
)
yield* get(ctx)
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
})
@@ -180,7 +185,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
request: HttpServerRequest.HttpServerRequest
}) {
const exists = yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
Effect.as(true),
Effect.map((info) => info.status === "running"),
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
)
if (!exists) return HttpServerResponse.empty({ status: 404 })
@@ -214,48 +219,53 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT())
return HttpServerResponse.empty()
}
const bridge = yield* EffectBridge.make()
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
bridge.fork(effect.pipe(Effect.catch(() => Effect.void)))
}
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data))
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
writeScoped(write(new Socket.CloseEvent(code, reason)))
},
}
const handler = yield* pty(
Pty.Service.use((service) => service.connect(ctx.params.ptyID, adapter, cursor)),
).pipe(
Effect.catchTag("Pty.NotFoundError", () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
),
)
if (!handler) return HttpServerResponse.empty()
// The handshake runs inside `socket.runRaw`, after the input callback is
// registered, so the client cannot send frames before PTY input is wired.
yield* socket
.runRaw((message) => handlePtyInput(handler, message))
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
// Outbound frames flow through one queue drained by a single writer so replay, live
// output, and the close frame keep their order.
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
const attachment = yield* pty(
Pty.Service.use((service) =>
service.attach(ctx.params.ptyID, {
cursor,
onData: (chunk) => Queue.offerUnsafe(outbox, chunk),
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
}),
),
).pipe(
Effect.catchTags({
"Pty.NotFoundError": () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
"Pty.ExitedError": () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
}),
)
if (!attachment) return HttpServerResponse.empty()
for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk)
Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor))
attachment.activate()
const drain = Effect.gen(function* () {
while (true) {
const item = yield* Queue.take(outbox)
yield* write(item)
if (item instanceof Socket.CloseEvent) return
}
})
// The reader runs concurrently with the writer; whichever finishes first ends the
// connection and the attachment is always released.
yield* Effect.race(
drain,
socket.runRaw((message) => {
const decoded = PtyProtocol.decodeInput(message)
if (decoded !== undefined) attachment.write(decoded)
}),
).pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(Effect.sync(() => attachment.detach())),
Effect.orDie,
)
return HttpServerResponse.empty()
}),
)
@@ -21,6 +21,7 @@ import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
@@ -70,7 +71,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { lazy } from "@/util/lazy"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
@@ -188,6 +189,7 @@ const serverRoutes = HttpApiBuilder.layer(Api).pipe(
// kilocode_change start - effective references must be ready before any V2 location consumer runs
Layer.provide(handlers.pipe(Layer.provide(locationServiceMapLayer), Layer.provide(referenceReconcilerLayer))),
// kilocode_change end
Layer.provide(PluginPtyEnvironment.layer),
Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
)
+15 -7
View File
@@ -11,7 +11,7 @@ import { HttpApiApp } from "./routes/instance/httpapi/server"
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
import { PublicApi } from "./routes/instance/httpapi/public"
import type { CorsOptions } from "./cors"
import type { CorsOptions } from "@opencode-ai/server/cors"
import { lazy } from "@/util/lazy"
import * as KiloListener from "@/kilocode/server/listener" // kilocode_change
@@ -76,7 +76,7 @@ export async function openapi() {
return OpenApi.fromApi(PublicApi)
}
export let url: URL
export let url: URL | undefined
export async function listen(opts: ListenOptions): Promise<Listener> {
const listener = await Effect.runPromise(listenEffect(opts))
@@ -94,16 +94,15 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
const state = yield* startWithPortFallback(opts)
const address = yield* tcpAddress(state)
const listenerUrl = makeURL(opts.hostname, address.port)
url = listenerUrl
const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
url = listenerUrl
return {
hostname: opts.hostname,
port: address.port,
url: listenerUrl,
urls: serverUrls(opts.hostname, address.port), // kilocode_change
stop: yield* makeStop(state, unpublishMdns),
stop: yield* makeStop(state, unpublishMdns, listenerUrl),
}
},
)
@@ -180,10 +179,19 @@ function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) {
})
}
function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>) {
function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>, listenerUrl: URL) {
return Effect.gen(function* () {
const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore))
const closeScopeOnce = yield* Effect.cached(Scope.close(state.scope, Exit.void).pipe(Effect.ignore))
const closeScopeOnce = yield* Effect.cached(
Scope.close(state.scope, Exit.void).pipe(
Effect.ignore,
Effect.ensuring(
Effect.sync(() => {
if (url === listenerUrl) url = undefined
}),
),
),
)
return (close?: boolean) =>
Effect.gen(function* () {
+3 -25
View File
@@ -35,7 +35,7 @@ import { SessionCompaction } from "./compaction"
import { SystemPrompt } from "./system"
import { Instruction } from "./instruction"
import { Plugin } from "../plugin"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps"
import { ToolRegistry } from "@/tool/registry"
import { MCP } from "../mcp"
import { LSP } from "@/lsp/lsp"
@@ -54,7 +54,7 @@ import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { ShellID } from "@/tool/shell/id"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Truncate } from "@/tool/truncate"
@@ -1698,28 +1698,6 @@ export const layer = Layer.effect(
if (step === 1)
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
if (step > 1 && lastFinished) {
for (const m of msgs) {
// kilocode_change start - compare chronology, not generated IDs
const finishedBeforeMessage =
latest.finishedMessage && KiloSessionMessageOrder.compare(latest.finishedMessage, m) < 0
if (m.info.role !== "user" || !finishedBeforeMessage) continue
// kilocode_change end
for (const p of m.parts) {
if (p.type !== "text" || p.ignored || p.synthetic) continue
if (!p.text.trim()) continue
p.text = [
"<system-reminder>",
"The user sent the following message:",
p.text,
"",
"Please address this message and continue with your tasks.",
"</system-reminder>",
].join("\n")
}
}
}
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
// kilocode_change start — ephemeral context injection + post-summary
@@ -1770,7 +1748,7 @@ export const layer = Layer.effect(
sessionID,
parentSessionID: session.parentID,
system,
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])],
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
+1 -1
View File
@@ -142,7 +142,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
if (!execute) continue
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
const transformed = ProviderTransform.schema(input.model, schema)
const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} })
item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) =>
run.promise(
+1 -1
View File
@@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { ShellID } from "./shell/id"
import * as Truncate from "./truncate"
+8
View File
@@ -0,0 +1,8 @@
export function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+9 -2
View File
@@ -517,7 +517,7 @@ describe("acp event routing", () => {
expect(harness.updates).toHaveLength(0)
})
it("emits synthetic pending before the first running tool update", async () => {
it("exposes the shell command on the synthetic pending tool call", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
@@ -527,7 +527,14 @@ describe("acp event routing", () => {
"tool_call",
"tool_call_update",
])
expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" })
expect(harness.updates[0]?.update).toMatchObject({
status: "pending",
toolCallId: "call_1",
title: "printf hello",
kind: "execute",
locations: [{ path: "/workspace" }],
rawInput: { cmd: "printf hello", cwd: "/workspace" },
})
expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
})
+8 -1
View File
@@ -1,3 +1,4 @@
import { resolve } from "path"
import { describe, expect, test } from "bun:test"
import {
completedToolContent,
@@ -37,7 +38,13 @@ describe("acp tool conversion", () => {
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
{ path: "/tmp/outside" },
])
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
// Relative workdir resolves against cwd via the platform path resolver (backslashes on Windows).
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "subdir") },
])
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Bus } from "@/bus"
import { BackgroundProcess } from "@/kilocode/background-process"
import { SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Hash } from "@opencode-ai/core/util/hash"
@@ -11,7 +11,7 @@ import { Plugin } from "@/plugin"
import { Truncate } from "@/tool/truncate"
import { Config } from "@/config/config"
import { Agent } from "@/agent/agent"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { testEffect } from "../lib/effect"
@@ -9,7 +9,7 @@ import { InteractiveTerminalTool } from "@/kilocode/tool/interactive-terminal"
import { Plugin } from "@/plugin"
import type { Permission } from "@/permission"
import { MessageID, SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Truncate } from "@/tool/truncate"
import type { Tool } from "@/tool/tool"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -20,7 +20,7 @@ import { SandboxStore } from "@/kilocode/sandbox/store"
import type { SessionID } from "@/session/schema"
import { Session } from "@/session/session"
import { SessionStatus } from "@/session/status"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture"
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import * as PowerShell from "@/kilocode/shell/shell"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
const command = `Write-Output "こんにちは 😀"; Write-Output '$value'; Write-Output \`tick\`
Write-Output "done"`
+34 -2
View File
@@ -1,6 +1,7 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect, mock, beforeEach } from "bun:test"
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
import { testEffect } from "../lib/effect"
@@ -40,6 +41,8 @@ interface MockClientState {
{ resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
>
closed: boolean
clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
notificationHandlers: Map<unknown, (...args: any[]) => any>
}
@@ -77,6 +80,7 @@ function getOrCreateClientState(name?: string): MockClientState {
promptPages: {},
resourcePages: {},
closed: false,
requestHandlers: new Map(),
notificationHandlers: new Map(),
}
clientStates.set(key, state)
@@ -151,8 +155,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
_state!: MockClientState
transport: any
constructor(_opts: any) {
constructor(_info: any, options?: MockClientState["clientOptions"]) {
clientCreateCount++
this._state = getOrCreateClientState(lastCreatedClientName)
this._state.clientOptions = options
}
async connect(transport: { start: () => Promise<void> }) {
@@ -162,6 +168,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
this._state = getOrCreateClientState(lastCreatedClientName)
}
setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise<any>) {
this._state.requestHandlers.set(schema, handler)
}
setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
this._state?.notificationHandlers.set(schema, handler)
}
@@ -319,6 +329,28 @@ it.instance(
)
// kilocode_change end
it.instance(
"advertises and lists the instance directory as its root",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
lastCreatedClientName = "roots"
yield* mcp.add("roots", { type: "local", command: ["echo", "test"] })
const state = getOrCreateClientState("roots")
expect(state.clientOptions?.capabilities?.roots).toEqual({})
expect(state.clientOptions?.capabilities?.roots?.listChanged).toBeUndefined()
const handler = state.requestHandlers.get(ListRootsRequestSchema)
expect(handler).toBeDefined()
const result = yield* Effect.promise(() => handler?.() ?? Promise.reject(new Error("roots handler missing")))
expect(result).toEqual({ roots: [{ uri: pathToFileURL(directory).href }] })
}),
),
{ config: { mcp: {} } },
)
it.instance(
"local mcp cwd resolves relative paths against instance directory",
() =>
@@ -87,6 +87,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
// Mock the MCP SDK Client
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient {
setRequestHandler() {}
async connect(transport: { start: () => Promise<void> }) {
await transport.start()
}
@@ -95,6 +95,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
// Mock the MCP SDK Client to trigger OAuth flow
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient {
setRequestHandler() {}
async connect(transport: { start: () => Promise<void> }) {
await transport.start()
}

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