diff --git a/.changeset/share-server-session-runtime.md b/.changeset/share-server-session-runtime.md
new file mode 100644
index 000000000000..16bcb8755801
--- /dev/null
+++ b/.changeset/share-server-session-runtime.md
@@ -0,0 +1,6 @@
+---
+"@kilocode/cli": patch
+"kilo-code": patch
+---
+
+Keep server controls and events connected to active sessions and subagents.
diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts
index e8e1e97879ce..3c6078e9d171 100644
--- a/packages/opencode/src/kilocode/server/httpapi/server.ts
+++ b/packages/opencode/src/kilocode/server/httpapi/server.ts
@@ -1,4 +1,10 @@
import { Layer } from "effect"
+import { FetchHttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
+import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
+import { compressionLayer } from "@/server/routes/instance/httpapi/middleware/compression"
+import { corsVaryFix } from "@/server/routes/instance/httpapi/middleware/cors-vary"
+import { errorLayer } from "@/server/routes/instance/httpapi/middleware/error"
+import { fenceLayer } from "@/server/routes/instance/httpapi/middleware/fence"
import { agentBuilderHandlers } from "./handlers/agent-builder"
import { backgroundProcessHandlers } from "./handlers/background-process"
@@ -29,3 +35,23 @@ export const provide = Layer.provide([
suggestionHandlers,
telemetryHandlers,
])
+
+export function provideListener(opts?: CorsOptions) {
+ const cors = HttpRouter.middleware(
+ HttpMiddleware.cors({
+ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, opts),
+ maxAge: 86_400,
+ }),
+ { global: true },
+ )
+ return Layer.provide([
+ errorLayer,
+ compressionLayer,
+ corsVaryFix,
+ fenceLayer,
+ cors,
+ FetchHttpClient.layer,
+ HttpServer.layerServices,
+ Layer.succeed(CorsConfig)(opts),
+ ])
+}
diff --git a/packages/opencode/src/kilocode/server/listener.ts b/packages/opencode/src/kilocode/server/listener.ts
new file mode 100644
index 000000000000..3981a96b290e
--- /dev/null
+++ b/packages/opencode/src/kilocode/server/listener.ts
@@ -0,0 +1,8 @@
+import { AppLayer } from "@/effect/app-runtime"
+import { memoMap } from "@opencode-ai/core/effect/memo-map"
+import { Layer, Scope } from "effect"
+
+export function build(layer: Layer.Layer, scope: Scope.Scope) {
+ // Keep listener transport state fresh while AppLayer reuses the process-wide services.
+ return Layer.buildWithMemoMap(Layer.fresh(layer).pipe(Layer.provide(AppLayer)), memoMap, scope)
+}
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts
index daa799b7a8b4..c110d06b3681 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts
@@ -8,5 +8,5 @@ import { sessionHandlers } from "./v2/session"
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe(
Layer.provide(v2LocationLayer),
- Layer.provide(SessionV2.defaultLayer),
+ Layer.provide(SessionV2.layer), // kilocode_change - use the application EventV2Bridge
)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 5f426cf13816..d8d7dcd54f1e 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -82,7 +82,12 @@ import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { v2Handlers } from "./handlers/v2"
import { workspaceHandlers } from "./handlers/workspace"
-import { provide as provideKiloHttpApiHandlers } from "@/kilocode/server/httpapi/server" // kilocode_change
+// kilocode_change start
+import {
+ provide as provideKiloHttpApiHandlers,
+ provideListener as provideKiloListenerRoutes,
+} from "@/kilocode/server/httpapi/server"
+// kilocode_change end
import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
import { disposeMiddleware } from "./lifecycle"
@@ -248,6 +253,14 @@ export function createRoutes(
)
}
+// kilocode_change start - keep listener routes local while application services come from AppRuntime
+export function createListenerRoutes(corsOptions?: CorsOptions) {
+ return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
+ provideKiloListenerRoutes(corsOptions),
+ )
+}
+// kilocode_change end
+
export const routes = createRoutes()
export const webHandler = lazy(() =>
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 9a448b78d626..81f5e55c4e97 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -13,6 +13,7 @@ import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
import { PublicApi } from "./routes/instance/httpapi/public"
import type { CorsOptions } from "./cors"
import { lazy } from "@/util/lazy"
+import * as KiloListener from "@/kilocode/server/listener" // kilocode_change
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -101,7 +102,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect Scope.close(scope, Exit.void).pipe(Effect.ignore)),
Effect.map(
diff --git a/packages/opencode/test/kilocode/server/listener-runtime.test.ts b/packages/opencode/test/kilocode/server/listener-runtime.test.ts
new file mode 100644
index 000000000000..c3bd6c78bf31
--- /dev/null
+++ b/packages/opencode/test/kilocode/server/listener-runtime.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, expect, test } from "bun:test"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import * as Log from "@opencode-ai/core/util/log"
+import { Effect } from "effect"
+import { AppRuntime } from "../../../src/effect/app-runtime"
+import { InstanceRef } from "../../../src/effect/instance-ref"
+import { Server } from "../../../src/server/server"
+import { SessionPaths } from "../../../src/server/routes/instance/httpapi/groups/session"
+import { SessionRunState } from "../../../src/session/run-state"
+import { SessionID } from "../../../src/session/schema"
+import { withTimeout } from "../../../src/util/timeout"
+import { resetDatabase } from "../../fixture/db"
+import { disposeAllInstances, reloadTestInstance, tmpdir } from "../../fixture/fixture"
+
+void Log.init({ print: false })
+
+const previous = {
+ flag: Flag.KILO_SERVER_PASSWORD,
+ env: process.env.KILO_SERVER_PASSWORD,
+}
+
+afterEach(async () => {
+ Flag.KILO_SERVER_PASSWORD = previous.flag
+ if (previous.env === undefined) delete process.env.KILO_SERVER_PASSWORD
+ else process.env.KILO_SERVER_PASSWORD = previous.env
+ await disposeAllInstances()
+ await resetDatabase()
+})
+
+test("listener aborts shared session runners", async () => {
+ Flag.KILO_SERVER_PASSWORD = undefined
+ delete process.env.KILO_SERVER_PASSWORD
+ await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+ const ctx = await reloadTestInstance({ directory: tmp.path })
+ const sessionID = SessionID.descending()
+ const started = Promise.withResolvers()
+ const stopped = Promise.withResolvers()
+ const running = AppRuntime.runPromise(
+ SessionRunState.Service.use((state) =>
+ state.ensureRunning(
+ sessionID,
+ Effect.interrupt,
+ Effect.sync(started.resolve).pipe(Effect.andThen(Effect.never), Effect.ensuring(Effect.sync(stopped.resolve))),
+ ),
+ ).pipe(Effect.provideService(InstanceRef, ctx)),
+ ).catch(() => undefined)
+
+ try {
+ await withTimeout(started.promise, 5_000, "timed out waiting for shared session")
+ const listener = await Server.listen({ hostname: "127.0.0.1", port: 0 })
+ try {
+ const response = await fetch(new URL(SessionPaths.abort.replace(":sessionID", sessionID), listener.url), {
+ method: "POST",
+ headers: { "x-kilo-directory": tmp.path },
+ })
+ expect(response.status).toBe(200)
+ await withTimeout(stopped.promise, 5_000, "listener did not interrupt the shared session")
+ } finally {
+ await withTimeout(listener.stop(true), 10_000, "timed out cleaning up shared-runtime listener")
+ }
+ } finally {
+ await AppRuntime.runPromise(
+ SessionRunState.Service.use((state) => state.cancel(sessionID)).pipe(Effect.provideService(InstanceRef, ctx)),
+ ).catch(() => undefined)
+ await running
+ }
+}, 20_000)
diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts
index ea654c5e51ce..a231c97b73ab 100644
--- a/script/check-opencode-promise-facades.ts
+++ b/script/check-opencode-promise-facades.ts
@@ -40,6 +40,7 @@ const testAllow: Record = {
"provider/provider.test.ts": { count: 3, reason: "existing runtime integration test" },
"server/experimental-session-list.test.ts": { count: 2, reason: "Kilo session list integration test" },
"server/httpapi-event.test.ts": { count: 6, reason: "event stream integration test" },
+ "kilocode/server/listener-runtime.test.ts": { count: 3, reason: "listener and AppRuntime integration test" },
"session/llm.test.ts": { count: 2, reason: "existing runtime integration test" },
"tool/recall.test.ts": { count: 11, reason: "existing runtime integration test" },
}