Merge pull request #8808 from Kilo-Org/imanolmaiztegui/kilo-opencode-v1.3.6

OpenCode v1.3.6
This commit is contained in:
Imanol Maiztegui
2026-04-12 19:01:45 +02:00
committed by GitHub
34 changed files with 402 additions and 878 deletions
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "1.3.5"
version = "1.3.6"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.5/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.6/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.5/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.6/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.5/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.6/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.5/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.6/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.5/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.6/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
@@ -165,26 +165,25 @@ Positionals:
message message to send [string] [default: []]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
--cloud-fork fetch session from cloud and continue locally (requires --session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
--dir directory to run in, path on remote server if attaching [string]
--port port for the local server (defaults to random port if no value provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string]
--thinking show thinking blocks [boolean] [default: false]
--auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false]
--help Show help [boolean]
--version Show version number [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"]
--auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
--dir directory to run in, path on remote server if attaching [string]
--port port for the local server (defaults to random port if no value provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string]
--thinking show thinking blocks [boolean] [default: false]
```
## kilo debug
+7 -13
View File
@@ -250,10 +250,6 @@ export const RunCommand = cmd({
describe: "fork the session before continuing (requires --continue or --session)",
type: "boolean",
})
.option("cloud-fork", {
describe: "fetch session from cloud and continue locally (requires --session)",
type: "boolean",
})
.option("share", {
type: "boolean",
describe: "share the session",
@@ -273,6 +269,13 @@ export const RunCommand = cmd({
default: "default",
describe: "format: default (formatted) or json (raw JSON events)",
})
// kilocode_change start - auto approve all permissions
.option("auto", {
type: "boolean",
describe: "auto-approve all permissions (for autonomous/pipeline usage)",
default: false,
})
// kilocode_change end
.option("file", {
alias: ["f"],
type: "string",
@@ -287,13 +290,11 @@ export const RunCommand = cmd({
type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
})
// kilocode_change start
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to KILO_SERVER_PASSWORD)",
})
// kilocode_change end
.option("dir", {
type: "string",
describe: "directory to run in, path on remote server if attaching",
@@ -311,13 +312,6 @@ export const RunCommand = cmd({
describe: "show thinking blocks",
default: false,
})
// kilocode_change start - auto approve all permissions
.option("auto", {
type: "boolean",
describe: "auto-approve all permissions (for autonomous/pipeline usage)",
default: false,
})
// kilocode_change end
)
},
handler: async (args) => {
+15 -88
View File
@@ -1,7 +1,7 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig } from "@opentui/core"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
Switch,
@@ -44,7 +44,6 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { isKiloError, showKiloErrorToast } from "@/kilocode/kilo-errors" // kilocode_change
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
@@ -58,24 +57,12 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { registerKiloCommands } from "@/kilocode/kilo-commands" // kilocode_change
import { KiloClawView } from "@/kilocode/claw/view" // kilocode_change
import { initializeTUIDependencies } from "@kilocode/kilo-gateway/tui" // kilocode_change
import * as KiloApp from "@/kilocode/cli/cmd/tui/app" // kilocode_change
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
// kilocode_change start
function isAllowEverything(permission: unknown): boolean {
if (typeof permission !== "object" || permission === null) return false
const wildcard = (permission as Record<string, unknown>)["*"]
if (typeof wildcard === "string") return wildcard === "allow"
if (typeof wildcard === "object" && wildcard !== null) return (wildcard as Record<string, unknown>)["*"] === "allow"
return false
}
// kilocode_change end
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
if (!process.stdin.isTTY) return "dark"
@@ -354,55 +341,38 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
// kilocode_change start — notify server which session the user is viewing (for live session indicators)
createEffect(() => {
const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
sdk.client.session.viewed({ focused: sessionID ? [sessionID] : [] }).catch(() => {})
})
// kilocode_change end
// kilocode_change start — evict per-session data from store when navigating away
createEffect(
on(
() => (route.data.type === "session" ? route.data.sessionID : undefined),
(current, prev) => {
if (prev && prev !== current) sync.session.evict(prev)
},
),
)
// kilocode_change end
KiloApp.useSessionEffects({ route, sdk, sync }) // kilocode_change
// Update terminal window title based on current route and session
createEffect(() => {
if (!terminalTitleEnabled() || Flag.KILO_DISABLE_TERMINAL_TITLE) return
const titleDefault = "Kilo CLI" // kilocode_change
const titleDefault = KiloApp.APP_TITLE // kilocode_change
if (route.data.type === "home") {
renderer.setTerminalTitle(titleDefault) // kilocode_change
renderer.setTerminalTitle(titleDefault)
return
}
if (route.data.type === "session") {
const session = sync.session.get(route.data.sessionID)
if (!session || SessionApi.isDefaultTitle(session.title)) {
renderer.setTerminalTitle(titleDefault) // kilocode_change
renderer.setTerminalTitle(titleDefault)
return
}
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`${titleDefault} | ${title}`) // kilocode_change
renderer.setTerminalTitle(`${titleDefault} | ${title}`)
return
}
if (route.data.type === "plugin") {
renderer.setTerminalTitle(`${titleDefault} | ${route.data.id}`) // kilocode_change
renderer.setTerminalTitle(`${titleDefault} | ${route.data.id}`)
}
// kilocode_change start
if (route.data.type === "kiloclaw") {
renderer.setTerminalTitle(`${titleDefault} | KiloClaw`)
}
const kiloTitle = KiloApp.getTerminalTitle(route, titleDefault)
if (kiloTitle) renderer.setTerminalTitle(kiloTitle)
// kilocode_change end
})
@@ -711,7 +681,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
title: "Open docs",
value: "docs.open",
onSelect: () => {
open("https://kilo.ai/docs").catch(() => {}) // kilocode_change
open(KiloApp.DOCS_URL).catch(() => {}) // kilocode_change
dialog.clear()
},
category: "System",
@@ -817,48 +787,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
// kilocode_change start
{
get title() {
return isAllowEverything(sync.data.config.permission) ? "Disable auto-approve mode" : "Enable auto-approve mode"
},
value: "permission.allow_everything",
category: "System",
onSelect: async (dialog) => {
const enabled = isAllowEverything(sync.data.config.permission)
const result = await sdk.client.permission.allowEverything({ enable: !enabled })
if (result.error) {
toast.show({
variant: "error",
message: `Failed to ${!enabled ? "enable" : "disable"} auto-approve mode`,
})
return
}
dialog.clear()
},
},
// kilocode_change end
])
// kilocode_change start - Initialize TUI dependencies for kilo-gateway
initializeTUIDependencies({
useCommandDialog: useCommandDialog,
useSync: useSync,
useDialog: useDialog,
useToast: useToast,
useTheme: useTheme,
useSDK: useSDK,
DialogAlert: DialogAlert,
DialogSelect: DialogSelect,
Link: Link,
Clipboard: Clipboard,
useKeyboard: useKeyboard,
TextAttributes: TextAttributes,
})
registerKiloCommands(useSDK)
// kilocode_change end
KiloApp.init() // kilocode_change
// kilocode_change - Delete OpenRouter Alert
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})
@@ -892,12 +824,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
sdk.event.on("session.error", (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
// kilocode_change start - Show warning toast for Kilo errors instead of generic error toast
if (error && typeof error === "object" && isKiloError(error)) {
showKiloErrorToast(error, toast)
return
}
// kilocode_change end
if (KiloApp.handleSessionError(error, toast)) return // kilocode_change
const message = errorMessage(error)
@@ -949,7 +876,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
await DialogAlert.show(
dialog,
"Update Complete",
`Successfully updated to Kilo v${result.data.version}. Please restart the application.`, // kilocode_change
`Successfully updated to ${KiloApp.APP_NAME} v${result.data.version}. Please restart the application.`, // kilocode_change
)
exit()
@@ -991,7 +918,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
</Match>
{/* kilocode_change start */}
<Match when={route.data.type === "kiloclaw"}>
<KiloClawView />
<KiloApp.KiloClawView />
</Match>
{/* kilocode_change end */}
</Switch>
@@ -24,7 +24,6 @@ export function DialogVariant() {
title={"Select variant"}
current={local.model.variant.current()}
flat={true}
skipFilter={true}
/>
)
}
@@ -1,173 +0,0 @@
// kilocode_change - retained; upstream moved session header into prompt footer in #19486, Kilo keeps the dedicated header UI
import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@kilocode/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { Flag } from "@/flag/flag"
import { useTerminalDimensions } from "@opentui/solid"
const Title = (props: { session: Accessor<Session> }) => {
const { theme } = useTheme()
return (
<text fg={theme.text}>
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
</text>
)
}
const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => {
const { theme } = useTheme()
return (
<Show when={props.context()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{props.context()} ({props.cost()})
</text>
</Show>
)
}
const WorkspaceInfo = (props: { workspace: Accessor<string | undefined> }) => {
const { theme } = useTheme()
return (
<Show when={props.workspace()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{props.workspace()}
</text>
</Show>
)
}
export function Header() {
const route = useRouteData("session")
const sync = useSync()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
let result = total.toLocaleString()
if (model?.limit.context) {
result += " " + Math.round((total / model.limit.context) * 100) + "%"
}
return result
})
const workspace = createMemo(() => {
const id = session()?.workspaceID
if (!id) return "Workspace local"
const info = sync.workspace.get(id)
if (!info) return `Workspace ${id}`
return `Workspace ${id} (${info.type})`
})
const { theme } = useTheme()
const keybind = useKeybind()
const command = useCommandDialog()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
return (
<box flexShrink={0}>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={theme.border}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
>
<Switch>
<Match when={session()?.parentID}>
<box flexDirection="column" gap={1}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
{Flag.KILO_EXPERIMENTAL_WORKSPACES ? (
<box flexDirection="column">
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<WorkspaceInfo workspace={workspace} />
</box>
) : (
<text fg={theme.text}>
<b>Subagent session</b>
</text>
)}
<ContextInfo context={context} cost={cost} />
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
</box>
</box>
</box>
</Match>
<Match when={true}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
{Flag.KILO_EXPERIMENTAL_WORKSPACES ? (
<box flexDirection="column">
<Title session={session} />
<WorkspaceInfo workspace={workspace} />
</box>
) : (
<Title session={session} />
)}
<ContextInfo context={context} cost={cost} />
</box>
</Match>
</Switch>
</box>
</box>
)
}
+1 -1
View File
@@ -21,7 +21,7 @@ import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session"
import { writeHeapSnapshot } from "v8"
declare global {
const KILO_WORKER_PATH: string // kilocode_change
const KILO_WORKER_PATH: string
}
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
@@ -32,14 +32,7 @@ export const WorktreeAdaptor: Adaptor = {
const config = Config.parse(info)
await Worktree.remove({ directory: config.directory })
},
async fetch(info, input: RequestInfo | URL, init?: RequestInit) {
const config = Config.parse(info)
const { WorkspaceServer } = await import("../workspace-server/server")
const url = input instanceof Request || input instanceof URL ? input : new URL(input, "http://kilo.internal")
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
headers.set("x-kilo-directory", config.directory)
const request = new Request(url, { ...init, headers })
return WorkspaceServer.App().fetch(request)
async fetch(_info, _input: RequestInfo | URL, _init?: RequestInit) {
throw new Error("fetch not implemented")
},
}
@@ -1,25 +0,0 @@
// kilocode_change - retained; upstream removed workspace-server subsystem in #19316, routing refactor incomplete in v1.3.5
import { Context } from "../util/context"
import type { WorkspaceID } from "./schema"
interface Context {
workspaceID?: WorkspaceID
}
const context = Context.create<Context>("workspace")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID }, async () => {
return input.fn()
})
},
get workspaceID() {
try {
return context.use().workspaceID
} catch (e) {
return undefined
}
},
}
@@ -1,51 +0,0 @@
// kilocode_change - retained; upstream removed workspace-server subsystem in #19316, routing refactor incomplete in v1.3.5
import type { MiddlewareHandler } from "hono"
import { Flag } from "../flag/flag"
import { getAdaptor } from "./adaptors"
import { Workspace } from "./workspace"
import { WorkspaceContext } from "./workspace-context"
// This middleware forwards all non-GET requests if the workspace is a
// remote. The remote workspace needs to handle session mutations
async function routeRequest(req: Request) {
// Right now, we need to forward all requests to the workspace
// because we don't have syncing. In the future all GET requests
// which don't mutate anything will be handled locally
//
// if (req.method === "GET") return
if (!WorkspaceContext.workspaceID) return
const workspace = await Workspace.get(WorkspaceContext.workspaceID)
if (!workspace) {
return new Response(`Workspace not found: ${WorkspaceContext.workspaceID}`, {
status: 500,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}
const adaptor = await getAdaptor(workspace.type)
return adaptor.fetch(workspace, `${new URL(req.url).pathname}${new URL(req.url).search}`, {
method: req.method,
body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(),
signal: req.signal,
headers: req.headers,
})
}
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => {
// Only available in development for now
if (!Flag.KILO_EXPERIMENTAL_WORKSPACES) {
// kilocode_change
return next()
}
const response = await routeRequest(c.req.raw)
if (response) {
return response
}
return next()
}
@@ -1,34 +0,0 @@
// kilocode_change - retained; upstream removed workspace-server subsystem in #19316, routing refactor incomplete in v1.3.5
import { GlobalBus } from "../../bus/global"
import { Hono } from "hono"
import { streamSSE } from "hono/streaming"
export function WorkspaceServerRoutes() {
return new Hono().get("/event", async (c) => {
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
const send = async (event: unknown) => {
await stream.writeSSE({
data: JSON.stringify(event),
})
}
const handler = async (event: { directory?: string; payload: unknown }) => {
await send(event.payload)
}
GlobalBus.on("event", handler)
await send({ type: "server.connected", properties: {} })
const heartbeat = setInterval(() => {
void send({ type: "server.heartbeat", properties: {} })
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolve()
})
})
})
})
}
@@ -1,66 +0,0 @@
// kilocode_change - retained; upstream removed workspace-server subsystem in #19316, routing refactor incomplete in v1.3.5
import { Hono } from "hono"
import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap"
import { SessionRoutes } from "../../server/routes/session"
import { WorkspaceServerRoutes } from "./routes"
import { WorkspaceContext } from "../workspace-context"
import { WorkspaceID } from "../schema"
export namespace WorkspaceServer {
export function App() {
const session = new Hono()
.use(async (c, next) => {
// Right now, we need handle all requests because we don't
// have syncing. In the future all GET requests will handled
// by the control plane
//
// if (c.req.method === "GET") return c.notFound()
await next()
})
.route("/", SessionRoutes())
return new Hono()
.use(async (c, next) => {
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-kilo-workspace")
const raw = c.req.query("directory") || c.req.header("x-kilo-directory")
if (rawWorkspaceID == null) {
throw new Error("workspaceID parameter is required")
}
if (raw == null) {
throw new Error("directory parameter is required")
}
const directory = (() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})()
return WorkspaceContext.provide({
workspaceID: WorkspaceID.make(rawWorkspaceID),
async fn() {
return Instance.provide({
directory,
init: InstanceBootstrap,
async fn() {
return next()
},
})
},
})
})
.route("/session", session)
.route("/", WorkspaceServerRoutes())
}
export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({
hostname: opts.hostname,
port: opts.port,
fetch: App().fetch,
})
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export namespace Global {
export const Path = {
// Allow override via KILO_TEST_HOME for test isolation
get home() {
return process.env.KILO_TEST_HOME || os.homedir() // kilocode_change
return process.env.KILO_TEST_HOME || os.homedir()
},
data,
bin: path.join(cache, "bin"),
+1 -1
View File
@@ -98,7 +98,7 @@ let cli = yargs(hideBin(process.argv)) // kilocode_change
})
.middleware(async (opts) => {
if (opts.pure) {
process.env.KILO_PURE = "1" // kilocode_change
process.env.KILO_PURE = "1"
}
await Log.init({
+2 -2
View File
@@ -61,8 +61,8 @@ export namespace Installation {
})
export type Info = z.infer<typeof Info>
export const VERSION = typeof KILO_VERSION === "string" ? KILO_VERSION : "local" // kilocode_change
export const CHANNEL = typeof KILO_CHANNEL === "string" ? KILO_CHANNEL : "local" // kilocode_change
export const VERSION = typeof KILO_VERSION === "string" ? KILO_VERSION : "local"
export const CHANNEL = typeof KILO_CHANNEL === "string" ? KILO_CHANNEL : "local"
export const USER_AGENT = `kilo/${CHANNEL}/${VERSION}/${Flag.KILO_CLIENT}` // kilocode_change
export function isPreview() {
@@ -1,6 +1,6 @@
import { ulid } from "ulid"
import type * as SDK from "@kilocode/sdk/v2"
import type { Session } from "@/session"
import type { KiloSession } from "@/kilocode/session"
export namespace IngestQueue {
export type Client = {
@@ -8,7 +8,7 @@ export namespace IngestQueue {
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
}
export type CloseReason = Session.CloseReason
export type CloseReason = KiloSession.CloseReason
export type Data =
| {
@@ -2,6 +2,7 @@ import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
import { KiloSession } from "@/kilocode/session"
import { SessionID } from "@/session/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { MessageV2 } from "@/session/message-v2"
@@ -607,7 +608,7 @@ export namespace KiloSessions {
}
async function meta(sessionId?: string) {
const override = sessionId ? Session.getPlatformOverride(sessionId) : undefined
const override = sessionId ? KiloSession.getPlatformOverride(sessionId) : undefined
const platform = override || process.env["KILO_PLATFORM"] || "cli"
const orgId = await getOrgId()
const gitBranch = await Vcs.branch().catch(() => undefined)
@@ -0,0 +1,177 @@
// kilocode_change - new file
/**
* Kilo-specific TUI app customizations.
*
* Everything in this module is called from the shared upstream `app.tsx`
* via thin integration points so the upstream diff stays minimal.
*/
import { createEffect, on } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { Clipboard } from "@tui/util/clipboard"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { useDialog } from "@tui/ui/dialog"
import { useToast } from "@tui/ui/toast"
import { useTheme } from "@tui/context/theme"
import { DialogAlert } from "@tui/ui/dialog-alert"
import { DialogSelect } from "@tui/ui/dialog-select"
import { Link } from "@tui/ui/link"
import { isKiloError, showKiloErrorToast } from "@/kilocode/kilo-errors"
import { registerKiloCommands } from "@/kilocode/kilo-commands"
import { initializeTUIDependencies } from "@kilocode/kilo-gateway/tui"
// Re-export so upstream can render the route without importing directly
export { KiloClawView } from "@/kilocode/claw/view"
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Default terminal window title. */
export const APP_TITLE = "Kilo CLI"
/** Public docs URL shown in the command palette. */
export const DOCS_URL = "https://kilo.ai/docs"
/** Human-readable product name used in user-facing messages. */
export const APP_NAME = "Kilo"
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
export function isAllowEverything(permission: unknown): boolean {
if (typeof permission !== "object" || permission === null) return false
const wildcard = (permission as Record<string, unknown>)["*"]
if (typeof wildcard === "string") return wildcard === "allow"
if (typeof wildcard === "object" && wildcard !== null) return (wildcard as Record<string, unknown>)["*"] === "allow"
return false
}
// ---------------------------------------------------------------------------
// Session effects
// ---------------------------------------------------------------------------
/**
* Reactive effects for session management:
* - Notify the server which session the user is viewing (live indicators)
* - Evict per-session data from the store when navigating away
*
* Must be called inside the App component body (needs SolidJS owner).
*/
export function useSessionEffects(deps: {
route: ReturnType<typeof import("@tui/context/route").useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
}) {
// Notify server which session the user is viewing
createEffect(() => {
const sessionID = deps.route.data.type === "session" ? deps.route.data.sessionID : undefined
deps.sdk.client.session.viewed({ focused: sessionID ? [sessionID] : [] }).catch(() => {})
})
// Evict per-session data from store when navigating away
createEffect(
on(
() => (deps.route.data.type === "session" ? deps.route.data.sessionID : undefined),
(current, prev) => {
if (prev && prev !== current) deps.sync.session.evict(prev)
},
),
)
}
// ---------------------------------------------------------------------------
// Terminal title
// ---------------------------------------------------------------------------
/**
* Returns the terminal title for kiloclaw routes.
* Returns undefined for other routes (caller should handle them).
*/
export function getTerminalTitle(
route: ReturnType<typeof import("@tui/context/route").useRoute>,
base: string,
): string | undefined {
if (route.data.type === "kiloclaw") return `${base} | KiloClaw`
return undefined
}
// ---------------------------------------------------------------------------
// Session error handling
// ---------------------------------------------------------------------------
/**
* Intercepts Kilo-specific errors and shows a warning toast.
* Returns `true` if the error was handled, `false` otherwise.
*/
export function handleSessionError(error: unknown, toast: ReturnType<typeof useToast>): boolean {
if (error && typeof error === "object" && isKiloError(error as any)) {
showKiloErrorToast(error as any, toast)
return true
}
return false
}
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
/**
* One-shot initialiser called from the App component body.
*
* - Injects TUI dependencies into kilo-gateway
* - Registers Kilo Gateway commands (profile, teams, kiloclaw, etc.)
* - Registers the auto-approve toggle command
*/
export function init() {
const command = useCommandDialog()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
// Inject TUI dependencies for kilo-gateway
initializeTUIDependencies({
useCommandDialog,
useSync,
useDialog,
useToast,
useTheme,
useSDK,
DialogAlert,
DialogSelect,
Link,
Clipboard,
useKeyboard,
TextAttributes,
})
// Register Kilo Gateway commands (profile, teams, kiloclaw, remote, etc.)
registerKiloCommands(useSDK)
// Register auto-approve toggle
command.register(() => [
{
get title() {
return isAllowEverything(sync.data.config.permission) ? "Disable auto-approve mode" : "Enable auto-approve mode"
},
value: "permission.allow_everything",
category: "System",
onSelect: async (dialog) => {
const enabled = isAllowEverything(sync.data.config.permission)
const result = await sdk.client.permission.allowEverything({ enable: !enabled })
if (result.error) {
toast.show({
variant: "error",
message: `Failed to ${!enabled ? "enable" : "disable"} auto-approve mode`,
})
return
}
dialog.clear()
},
},
])
}
-2
View File
@@ -260,9 +260,7 @@ export namespace Project {
time: { created: Date.now(), updated: Date.now() },
}
// kilocode_change start
if (Flag.KILO_EXPERIMENTAL_ICON_DISCOVERY) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
// kilocode_change end
const result: Info = {
...existing,
+3 -4
View File
@@ -7,7 +7,6 @@ import { Filesystem } from "@/util/filesystem"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceRoutes } from "./instance"
import { HEADER_DIRECTORY, HEADER_WORKSPACE } from "../kilocode/server/router" // kilocode_change
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -28,7 +27,7 @@ function local(method: string, path: string) {
const routes = lazy(() => InstanceRoutes())
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c) => {
const raw = c.req.query("directory") || c.req.header(HEADER_DIRECTORY) || process.cwd() // kilocode_change
const raw = c.req.query("directory") || c.req.header("x-kilo-directory") || process.cwd()
const directory = Filesystem.resolve(
(() => {
try {
@@ -40,7 +39,7 @@ export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c) => {
)
const url = new URL(c.req.url)
const workspaceParam = url.searchParams.get("workspace") || c.req.header(HEADER_WORKSPACE) // kilocode_change
const workspaceParam = url.searchParams.get("workspace")
// TODO: If session is being routed, force it to lookup the
// project/workspace
@@ -89,7 +88,7 @@ export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c) => {
const adaptor = await getAdaptor(workspace.type)
const headers = new Headers(c.req.raw.headers)
headers.delete(HEADER_WORKSPACE) // kilocode_change
headers.delete("x-kilo-workspace")
return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
method: c.req.method,
+5 -30
View File
@@ -34,7 +34,6 @@ import { ModelID, ProviderID } from "@/provider/schema"
import { Permission } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
@@ -252,12 +251,6 @@ export namespace Session {
// kilocode_change end
}
// kilocode_change
export type CloseReason = KiloSession.CloseReason
// kilocode_change
export const getPlatformOverride = KiloSession.getPlatformOverride
export function plan(input: { slug: string; time: { created: number } }) {
const base = Instance.project.vcs
? path.join(Instance.worktree, ".kilo", "plans") // kilocode_change
@@ -289,27 +282,12 @@ export namespace Session {
0) as number,
)
// OpenRouter provides inputTokens as the total count of input tokens (including cached).
// AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment)
// Anthropic does it differently though - inputTokens doesn't include cached tokens.
// It looks like OpenCode's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others.
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
const adjustedInputTokens = safe(
excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens,
)
// AI SDK v6 normalized inputTokens to include cached tokens across all providers
// (including Anthropic/Bedrock which previously excluded them). Always subtract cache
// tokens to get the non-cached input count for separate cost calculation.
const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens)
const total = iife(() => {
// Anthropic doesn't provide total_tokens, also ai sdk will vastly undercount if we
// don't compute from components
if (
input.model.api.npm === "@ai-sdk/anthropic" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock" ||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
) {
return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
}
return input.usage.totalTokens
})
const total = input.usage.totalTokens
const tokens = {
total,
@@ -472,13 +450,10 @@ export namespace Session {
const cfg = yield* config.get()
if (!result.parentID && (Flag.KILO_AUTO_SHARE || cfg.share === "auto")) {
// kilocode_change
// kilocode_change
yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope))
}
if (!Flag.KILO_EXPERIMENTAL_WORKSPACES) {
// kilocode_change
// This only exist for backwards compatibility. We should not be
// manually publishing this event; it is a sync event now
yield* bus.publish(Event.Updated, {
+8 -1
View File
@@ -38,6 +38,13 @@ interface FetchDecompressionError extends Error {
path: string
}
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
code: "ZlibError"
errno: number
path: string
}
export namespace MessageV2 {
export function isMedia(mime: string) {
return mime.startsWith("image/") || mime === "application/pdf"
@@ -958,7 +965,7 @@ export namespace MessageV2 {
)
return rows.map(
(row) =>
// kilcode_change - apply stripping to parts fetched individually as well to cover all read paths
// kilocode_change - apply stripping to parts fetched individually as well to cover all read paths
stripPartMetadata({
...row.data,
id: row.id,
+4 -3
View File
@@ -2,6 +2,7 @@ import path from "path"
import os from "os"
import fs from "fs/promises"
import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change
import { KiloSession } from "@/kilocode/session" // kilocode_change
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { SessionID, MessageID, PartID } from "./schema"
@@ -301,7 +302,7 @@ export namespace SessionPrompt {
// kilocode_change start
void Bus.publish(Session.Event.TurnOpen, { sessionID })
let closeReason: Session.CloseReason = "completed"
let closeReason: KiloSession.CloseReason = "completed"
let finished = false
await using _ = defer(() => cancel(sessionID))
await using _close = defer(async () => {
@@ -1441,10 +1442,10 @@ export namespace SessionPrompt {
// Original logic when experimental plan mode is disabled
if (!Flag.KILO_EXPERIMENTAL_PLAN_MODE) {
// kilocode_change - inject plan file path so agent writes to .kilo/plans/
// kilocode_change start - inject plan file path so agent writes to .kilo/plans/
await KiloSessionPrompt.insertPlanReminders({ agent: input.agent, session: input.session, userMessage })
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
// kilocode_change start - renamed from "build" to "code"
// kilocode_change - renamed from "build" to "code"
if (wasPlan && input.agent.name === "code") {
// kilocode_change end
userMessage.parts.push({
+1 -1
View File
@@ -27,7 +27,7 @@ export namespace Skill {
// kilocode_change end
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const KILO_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" // kilocode_change
const KILO_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
export const Info = z.object({
+18 -98
View File
@@ -132,13 +132,13 @@ export namespace Snapshot {
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
// kilocode_change start — ACP guard: disable snapshots for ACP clients
const enabled = Effect.fnUntraced(function* () {
if (state.vcs !== "git") return false
// kilocode_change start - ACP guard: disable snapshots for ACP clients
if (KiloSnapshot.acpDisabled()) return false
// kilocode_change end
return (yield* config.get()).snapshot !== false
})
// kilocode_change end
const excludes = Effect.fnUntraced(function* () {
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
@@ -311,98 +311,35 @@ export namespace Snapshot {
)
})
// kilocode_change start — batched revert: group up to 100 files per git checkout
const revertSingle = Effect.fnUntraced(function* (op: KiloSnapshot.RevertOp) {
log.info("reverting", { file: op.file, hash: op.hash })
const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], {
cwd: state.worktree,
})
if (result.code === 0) return
const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], {
cwd: state.worktree,
})
if (tree.code === 0 && tree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash })
return
}
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
yield* remove(op.file)
})
const revertBatch = Effect.fnUntraced(function* (batch: KiloSnapshot.RevertOp[]) {
const hash = batch[0]!.hash
const tree = yield* git(
[...quote, ...args(["ls-tree", "--name-only", hash, "--", ...batch.map((op) => op.rel)])],
{ cwd: state.worktree },
)
if (tree.code !== 0) {
log.info("batched ls-tree failed, falling back to single-file revert", { hash, files: batch.length })
for (const op of batch) yield* revertSingle(op)
return
}
const existing = new Set(
tree.text
.trim()
.split("\n")
.map((l) => l.trim())
.filter(Boolean),
)
const toCheckout = batch.filter((op) => existing.has(op.rel))
if (toCheckout.length) {
log.info("reverting", { hash, files: toCheckout.length })
const result = yield* git(
[...core, ...args(["checkout", hash, "--", ...toCheckout.map((op) => op.file)])],
{ cwd: state.worktree },
)
if (result.code !== 0) {
log.info("batched checkout failed, falling back to single-file revert", {
hash,
files: toCheckout.length,
})
for (const op of batch) yield* revertSingle(op)
return
}
}
for (const op of batch) {
if (existing.has(op.rel)) continue
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
yield* remove(op.file)
}
})
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
return yield* locked(
Effect.gen(function* () {
const ops: KiloSnapshot.RevertOp[] = []
const seen = new Set<string>()
for (const item of patches) {
for (const file of item.files) {
if (seen.has(file)) continue
seen.add(file)
ops.push({
hash: item.hash,
file,
rel: path.relative(state.worktree, file).replaceAll("\\", "/"),
log.info("reverting", { file, hash: item.hash })
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
cwd: state.worktree,
})
}
}
for (const batch of KiloSnapshot.groupIntoBatches(ops)) {
if (batch.length === 1) {
yield* revertSingle(batch[0]!)
} else {
yield* revertBatch(batch)
if (result.code !== 0) {
const rel = path.relative(state.worktree, file)
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
cwd: state.worktree,
})
if (tree.code === 0 && tree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { file })
} else {
log.info("file did not exist in snapshot, deleting", { file })
yield* remove(file)
}
}
}
}
}),
)
})
// kilocode_change end
const diff = Effect.fnUntraced(function* (hash: string) {
return yield* locked(
@@ -454,24 +391,7 @@ export namespace Snapshot {
const [adds, dels, file] = line.split("\t")
if (!file) continue
const binary = adds === "-" && dels === "-"
// kilocode_change start — skip oversized files
let skip = binary
if (!binary) {
const [fromSize, toSize] = yield* Effect.all(
[
git(["--git-dir", state.gitdir, "cat-file", "-s", `${from}:${file}`]).pipe(
Effect.map((r) => parseInt(r.text) || 0),
),
git(["--git-dir", state.gitdir, "cat-file", "-s", `${to}:${file}`]).pipe(
Effect.map((r) => parseInt(r.text) || 0),
),
],
{ concurrency: 2 },
)
skip = KiloSnapshot.oversized(fromSize) || KiloSnapshot.oversized(toSize)
}
// kilocode_change end
const [before, after] = skip
const [before, after] = binary
? ["", ""]
: yield* Effect.all(
[
-2
View File
@@ -115,10 +115,8 @@ export namespace ToolRegistry {
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get()
// kilcoode_change start
const question =
["app", "cli", "desktop", "vscode"].includes(Flag.KILO_CLIENT) || Flag.KILO_ENABLE_QUESTION_TOOL
// kilocode_change end
return [
InvalidTool,
+2 -1
View File
@@ -966,7 +966,7 @@ test("serializes config dependency installs across dirs", async () => {
await gate
}
}
const mod = path.join(opts?.cwd ?? "", "node_modules", "@kilocode", "plugin")
const mod = path.join(opts?.cwd ?? "", "node_modules", "@kilocode", "plugin") // kilocode_change
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
@@ -2031,6 +2031,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
})
test("skips project .kilo/ directories when flag is set", async () => {
// kilocode_change
const originalEnv = process.env["KILO_DISABLE_PROJECT_CONFIG"]
process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true"
@@ -1,160 +0,0 @@
// kilocode_change - retained; upstream removed workspace-server subsystem in #19316, routing refactor incomplete in v1.3.5
import { afterEach, describe, expect, mock, test } from "bun:test"
import { WorkspaceID } from "../../src/control-plane/schema"
import { Hono } from "hono"
import { tmpdir } from "../fixture/fixture"
import { Project } from "../../src/project/project"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Instance } from "../../src/project/instance"
import { WorkspaceContext } from "../../src/control-plane/workspace-context"
import { Database } from "../../src/storage/db"
import { resetDatabase } from "../fixture/db"
import * as adaptors from "../../src/control-plane/adaptors"
import type { Adaptor } from "../../src/control-plane/types"
import { Flag } from "../../src/flag/flag"
afterEach(async () => {
mock.restore()
await resetDatabase()
})
const original = Flag.KILO_EXPERIMENTAL_WORKSPACES
// @ts-expect-error don't do this normally, but it works
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
afterEach(() => {
// @ts-expect-error don't do this normally, but it works
Flag.KILO_EXPERIMENTAL_WORKSPACES = original
})
type State = {
workspace?: "first" | "second"
calls: Array<{ method: string; url: string; body?: string }>
}
const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert
async function setup(state: State) {
const TestAdaptor: Adaptor = {
configure(config) {
return config
},
async create() {
throw new Error("not used")
},
async remove() {},
async fetch(_config: unknown, input: RequestInfo | URL, init?: RequestInit) {
const url =
input instanceof Request || input instanceof URL
? input.toString()
: new URL(input, "http://workspace.test").toString()
const request = new Request(url, init)
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.text()
state.calls.push({
method: request.method,
url: `${new URL(request.url).pathname}${new URL(request.url).search}`,
body,
})
return new Response("proxied", { status: 202 })
},
}
adaptors.installAdaptor("testing", TestAdaptor)
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const id1 = WorkspaceID.ascending()
const id2 = WorkspaceID.ascending()
Database.use((db) =>
db
.insert(WorkspaceTable)
.values([
{
id: id1,
branch: "main",
project_id: project.id,
type: remote.type,
name: remote.name,
},
{
id: id2,
branch: "main",
project_id: project.id,
type: "worktree",
directory: tmp.path,
name: "local",
},
])
.run(),
)
const { WorkspaceRouterMiddleware } = await import("../../src/control-plane/workspace-router-middleware")
const app = new Hono().use(WorkspaceRouterMiddleware)
return {
id1,
id2,
app,
async request(input: RequestInfo | URL, init?: RequestInit) {
return Instance.provide({
directory: tmp.path,
fn: async () =>
WorkspaceContext.provide({
workspaceID: state.workspace === "first" ? id1 : id2,
fn: () => app.request(input, init),
}),
})
},
}
}
describe("control-plane/session-proxy-middleware", () => {
test("forwards non-GET session requests for workspaces", async () => {
const state: State = {
workspace: "first",
calls: [],
}
const ctx = await setup(state)
ctx.app.post("/session/foo", (c) => c.text("local", 200))
const response = await ctx.request("http://workspace.test/session/foo?x=1", {
method: "POST",
body: JSON.stringify({ hello: "world" }),
headers: {
"content-type": "application/json",
},
})
expect(response.status).toBe(202)
expect(await response.text()).toBe("proxied")
expect(state.calls).toEqual([
{
method: "POST",
url: "/session/foo?x=1",
body: '{"hello":"world"}',
},
])
})
// It will behave this way when we have syncing
//
// test("does not forward GET requests", async () => {
// const state: State = {
// workspace: "first",
// calls: [],
// }
// const ctx = await setup(state)
// ctx.app.get("/session/foo", (c) => c.text("local", 200))
// const response = await ctx.request("http://workspace.test/session/foo?x=1")
// expect(response.status).toBe(200)
// expect(await response.text()).toBe("local")
// expect(state.calls).toEqual([])
// })
})
@@ -1,71 +0,0 @@
// kilocode_change - retained; upstream removed workspace-server subsystem in #19316, routing refactor incomplete in v1.3.5
import { afterEach, describe, expect, test } from "bun:test"
import { Log } from "../../src/util/log"
import { WorkspaceServer } from "../../src/control-plane/workspace-server/server"
import { parseSSE } from "../../src/control-plane/sse"
import { GlobalBus } from "../../src/bus/global"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await resetDatabase()
})
Log.init({ print: false })
describe("control-plane/workspace-server SSE", () => {
test("streams GlobalBus events and parseSSE reads them", async () => {
await using tmp = await tmpdir({ git: true })
const app = WorkspaceServer.App()
const stop = new AbortController()
const seen: unknown[] = []
try {
const response = await app.request("/event", {
signal: stop.signal,
headers: {
"x-kilo-workspace": "wrk_test_workspace",
"x-kilo-directory": tmp.path,
},
})
expect(response.status).toBe(200)
expect(response.body).toBeDefined()
const done = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("timed out waiting for workspace.test event"))
}, 3000)
void parseSSE(response.body!, stop.signal, (event) => {
seen.push(event)
const next = event as { type?: string }
if (next.type === "server.connected") {
GlobalBus.emit("event", {
payload: {
type: "workspace.test",
properties: { ok: true },
},
})
return
}
if (next.type !== "workspace.test") return
clearTimeout(timeout)
resolve()
}).catch((error) => {
clearTimeout(timeout)
reject(error)
})
})
await done
expect(seen.some((event) => (event as { type?: string }).type === "server.connected")).toBe(true)
expect(seen).toContainEqual({
type: "workspace.test",
properties: { ok: true },
})
} finally {
stop.abort()
}
})
})
@@ -0,0 +1,111 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
const disableDefault = process.env.KILO_DISABLE_DEFAULT_PLUGINS
process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const { Instance } = await import("../../src/project/instance")
afterEach(async () => {
await Instance.disposeAll()
})
afterAll(() => {
if (disableDefault === undefined) {
delete process.env.KILO_DISABLE_DEFAULT_PLUGINS
return
}
process.env.KILO_DISABLE_DEFAULT_PLUGINS = disableDefault
})
async function project(source: string) {
return tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
await Bun.write(file, source)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
)
},
})
}
describe("plugin.trigger", () => {
test("runs synchronous hooks without crashing", async () => {
await using tmp = await project(
[
"export default async () => ({",
' "experimental.chat.system.transform": (_input, output) => {',
' output.system.unshift("sync")',
" },",
"})",
"",
].join("\n"),
)
const out = await Instance.provide({
directory: tmp.path,
fn: async () => {
const out = { system: [] as string[] }
await Plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
},
})
expect(out.system).toEqual(["sync"])
})
test("awaits asynchronous hooks", async () => {
await using tmp = await project(
[
"export default async () => ({",
' "experimental.chat.system.transform": async (_input, output) => {',
" await Bun.sleep(1)",
' output.system.unshift("async")',
" },",
"})",
"",
].join("\n"),
)
const out = await Instance.provide({
directory: tmp.path,
fn: async () => {
const out = { system: [] as string[] }
await Plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
},
})
expect(out.system).toEqual(["async"])
})
})
@@ -964,8 +964,9 @@ describe("session.getUsage", () => {
expect(result.tokens.cache.write).toBe(300)
})
test("does not subtract cached tokens for anthropic provider", () => {
test("subtracts cached tokens for anthropic provider", () => {
const model = createModel({ context: 100_000, output: 32_000 })
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
const result = Session.getUsage({
model,
usage: {
@@ -979,7 +980,7 @@ describe("session.getUsage", () => {
},
})
expect(result.tokens.input).toBe(1000)
expect(result.tokens.input).toBe(800)
expect(result.tokens.cache.read).toBe(200)
})
@@ -1267,11 +1268,10 @@ describe("session.getUsage", () => {
"computes total from components for %s models",
(npm) => {
const model = createModel({ context: 100_000, output: 32_000, npm })
// AI SDK v6: inputTokens includes cached tokens for all providers
const usage = {
inputTokens: 1000,
outputTokens: 500,
// These providers typically report total as input + output only,
// excluding cache read/write.
totalTokens: 1500,
cachedInputTokens: 200,
}
@@ -1288,10 +1288,12 @@ describe("session.getUsage", () => {
},
})
expect(result.tokens.input).toBe(1000)
// inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500
expect(result.tokens.input).toBe(500)
expect(result.tokens.cache.read).toBe(200)
expect(result.tokens.cache.write).toBe(300)
expect(result.tokens.total).toBe(2000)
// total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300)
expect(result.tokens.total).toBe(1500)
return
}
@@ -1305,10 +1307,12 @@ describe("session.getUsage", () => {
},
})
expect(result.tokens.input).toBe(1000)
// inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500
expect(result.tokens.input).toBe(500)
expect(result.tokens.cache.read).toBe(200)
expect(result.tokens.cache.write).toBe(300)
expect(result.tokens.total).toBe(2000)
// total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300)
expect(result.tokens.total).toBe(1500)
},
)
})
+1 -1
View File
@@ -158,7 +158,7 @@ export const Script = {
return IS_PREVIEW
},
get release(): boolean {
return !!env.KILO_RELEASE // kilocode_change
return !!env.KILO_RELEASE
},
get team() {
return team
+1 -1
View File
@@ -24,7 +24,7 @@ function rewrite(request: Request, directory?: string) {
url.searchParams.set("directory", value)
}
const next = new Request(url.href, request) // kilocode_change - Bun Request() only accepts string | Request
const next = new Request(url.href, request)
next.headers.delete("x-kilo-directory")
return next
}
+1 -1
View File
@@ -37,7 +37,7 @@ function rewrite(request: Request, values: { directory?: string; workspace?: str
if (!changed) return request
const next = new Request(url.href, request) // kilocode_change - Bun Request() only accepts string | Request
const next = new Request(url, request)
next.headers.delete("x-kilo-directory")
next.headers.delete("x-kilo-workspace")
return next