diff --git a/bun.lock b/bun.lock
index 10c966918ba..fd3e6acabc4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -403,6 +403,7 @@
"remeda": "catalog:",
"simple-git": "3.31.1",
"solid-js": "catalog:",
+ "stream-chat": "9.38.0",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"turndown": "7.2.0",
@@ -1983,6 +1984,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+ "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
+
"@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="],
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
@@ -3261,6 +3264,8 @@
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
+ "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="],
+
"load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -3977,6 +3982,8 @@
"storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="],
+ "stream-chat": ["stream-chat@9.38.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.8", "@types/ws": "^8.5.14", "axios": "^1.12.2", "base64-js": "^1.5.1", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "jsonwebtoken": "^9.0.3", "linkifyjs": "^4.3.2", "ws": "^8.18.1" } }, "sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg=="],
+
"streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
diff --git a/packages/kilo-docs/pages/customize/agents-md.md b/packages/kilo-docs/pages/customize/agents-md.md
index d451b5e7a7d..cd34077e7a0 100644
--- a/packages/kilo-docs/pages/customize/agents-md.md
+++ b/packages/kilo-docs/pages/customize/agents-md.md
@@ -18,7 +18,7 @@ If you'd like to migrate your memory bank content to AGENTS.md:
1. Examine the contents in `.kilocode/rules/memory-bank/`
2. Move that content into your project's `AGENTS.md` file (or ask Kilo to do it for you)
-{% /callout %}
+ {% /callout %}
## What is AGENTS.md?
diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts
index 79a27b7f5f5..7dba7619039 100644
--- a/packages/kilo-gateway/src/server/routes.ts
+++ b/packages/kilo-gateway/src/server/routes.ts
@@ -496,6 +496,103 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
}
},
)
+ .get(
+ "/claw/status",
+ describeRoute({
+ summary: "Get KiloClaw instance status",
+ description: "Fetch the user's KiloClaw instance status via the KiloClaw worker",
+ operationId: "kilo.claw.status",
+ responses: {
+ 200: {
+ description: "Instance status",
+ content: {
+ "application/json": {
+ schema: resolver(z.unknown()),
+ },
+ },
+ },
+ ...errors(401, 502),
+ },
+ }),
+ async (c: any) => {
+ try {
+ const auth = await Auth.get("kilo")
+ if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401)
+ const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
+ if (!token) return c.json({ error: "No valid token found" }, 401)
+
+ const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ })
+
+ if (!response.ok) {
+ const text = await response.text()
+ return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any)
+ }
+
+ return c.json(await response.json())
+ } catch (err: any) {
+ console.error("[Kilo Gateway] claw/status: error", err?.message ?? err)
+ return c.json({ error: "Failed to reach KiloClaw" }, 502)
+ }
+ },
+ )
+ .get(
+ "/claw/chat-credentials",
+ describeRoute({
+ summary: "Get KiloClaw chat credentials",
+ description: "Fetch Stream Chat credentials for the user's KiloClaw instance",
+ operationId: "kilo.claw.chatCredentials",
+ responses: {
+ 200: {
+ description: "Stream Chat credentials or null",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z
+ .object({
+ apiKey: z.string(),
+ userId: z.string(),
+ userToken: z.string(),
+ channelId: z.string(),
+ })
+ .nullable(),
+ ),
+ },
+ },
+ },
+ ...errors(401, 502),
+ },
+ }),
+ async (c: any) => {
+ try {
+ const auth = await Auth.get("kilo")
+ if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401)
+ const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
+ if (!token) return c.json({ error: "No valid token found" }, 401)
+
+ const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/chat-credentials`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ })
+
+ if (!response.ok) {
+ const text = await response.text()
+ return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any)
+ }
+
+ return c.json(await response.json())
+ } catch (err: any) {
+ console.error("[Kilo Gateway] claw/chat-credentials: error", err?.message ?? err)
+ return c.json({ error: "Failed to reach KiloClaw" }, 502)
+ }
+ },
+ )
.get(
"/cloud-sessions",
describeRoute({
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 8180756d0b8..58550a202db 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -128,6 +128,7 @@
"remeda": "catalog:",
"simple-git": "3.31.1",
"solid-js": "catalog:",
+ "stream-chat": "9.38.0",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"turndown": "7.2.0",
diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts
index 170c4671b68..6f1168c98c8 100644
--- a/packages/opencode/src/cli/cmd/pr.ts
+++ b/packages/opencode/src/cli/cmd/pr.ts
@@ -97,7 +97,8 @@ export const PrCommand = cmd({
// Launch opencode TUI with session ID if available
const { spawn } = await import("child_process")
const opencodeArgs = sessionId ? ["-s", sessionId] : []
- const opencodeProcess = spawn(bin, opencodeArgs, { // kilocode_change
+ const opencodeProcess = spawn(bin, opencodeArgs, {
+ // kilocode_change
stdio: "inherit",
cwd: process.cwd(),
windowsHide: true, // kilocode_change - prevent CMD window flash on Windows
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 1cdd7a9e58d..556150729c3 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -317,7 +317,7 @@ export const RunCommand = cmd({
describe: "auto-approve all permissions (for autonomous/pipeline usage)",
default: false,
})
- // kilocode_change end
+ // kilocode_change end
)
},
handler: async (args) => {
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 2fdc41b3934..843b4a41853 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -43,6 +43,7 @@ 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 { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
@@ -301,6 +302,12 @@ function App() {
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`${titleDefault} | ${title}`) // kilocode_change
}
+
+ // kilocode_change start
+ if (route.data.type === "kiloclaw") {
+ renderer.setTerminalTitle(`${titleDefault} | KiloClaw`)
+ }
+ // kilocode_change end
})
const args = useArgs()
@@ -812,6 +819,11 @@ function App() {
+ {/* kilocode_change start */}
+
+
+
+ {/* kilocode_change end */}
)
diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx
index 06565838777..d9a4cc164d0 100644
--- a/packages/opencode/src/cli/cmd/tui/context/route.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx
@@ -13,7 +13,13 @@ export type SessionRoute = {
initialPrompt?: PromptInfo
}
-export type Route = HomeRoute | SessionRoute
+// kilocode_change start
+export type KiloClawRoute = {
+ type: "kiloclaw"
+}
+// kilocode_change end
+
+export type Route = HomeRoute | SessionRoute | KiloClawRoute // kilocode_change
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -26,14 +32,27 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
},
)
+ // kilocode_change start
+ let previous: Route | undefined
+ // kilocode_change end
+
return {
get data() {
return store
},
navigate(route: Route) {
console.log("navigate", route)
+ previous = { ...store } // kilocode_change
setStore(route)
},
+ // kilocode_change start
+ back() {
+ const target = previous ?? ({ type: "home" } as const)
+ previous = undefined
+ console.log("navigate", target)
+ setStore(target)
+ },
+ // kilocode_change end
}
},
})
diff --git a/packages/opencode/src/kilocode/claw/chat.tsx b/packages/opencode/src/kilocode/claw/chat.tsx
new file mode 100644
index 00000000000..7a479e08163
--- /dev/null
+++ b/packages/opencode/src/kilocode/claw/chat.tsx
@@ -0,0 +1,216 @@
+// kilocode_change - new file
+
+/**
+ * KiloClaw chat panel
+ *
+ * Renders a scrollable message list and a textarea input for chatting
+ * with the KiloClaw bot via Stream Chat.
+ *
+ * Visual style mirrors the session chat TUI (routes/session/index.tsx).
+ */
+
+import { createEffect, createMemo, For, Show } from "solid-js"
+import { type KeyBinding, type MouseEvent, type TextareaRenderable } from "@opentui/core"
+import { useRenderer } from "@opentui/solid"
+import { useTheme } from "@tui/context/theme"
+import { SplitBorder, EmptyBorder } from "@tui/component/border"
+import { useKV } from "@tui/context/kv"
+import type { ChatMessage } from "./types"
+
+function UserMessageRow(props: { msg: ChatMessage; index: number }) {
+ const { theme } = useTheme()
+ return (
+
+
+
+ {props.msg.text}
+
+
+
+ )
+}
+
+function BotMessageRow(props: { msg: ChatMessage; index: number }) {
+ const { theme, syntax } = useTheme()
+ const empty = () => !props.msg.text || !props.msg.text.trim()
+ return (
+
+
+ Thinking...}>
+
+
+
+
+ )
+}
+
+export function ClawChat(props: {
+ messages: ChatMessage[]
+ online: boolean
+ connected: boolean
+ loading: boolean
+ error: string | null
+ disabled: boolean
+ onSend: (text: string) => void
+}) {
+ const { theme } = useTheme()
+ const renderer = useRenderer()
+ const kv = useKV()
+ const [showScrollbar] = kv.signal("scrollbar_visible", true)
+ let input: TextareaRenderable
+
+ const active = createMemo(() => !props.disabled && props.connected)
+
+ const placeholder = createMemo(() => {
+ if (props.error) return props.error
+ if (props.loading) return "Connecting..."
+ if (!props.connected) return "Chat unavailable"
+ if (props.disabled) return "Instance is stopped"
+ return ""
+ })
+
+ const submit = () => {
+ if (!input) return
+ const text = input.plainText.trim()
+ if (!text) return
+ if (!active()) return
+ props.onSend(text)
+ input.clear()
+ }
+
+ createEffect(() => {
+ if (active()) input?.focus()
+ })
+
+ // Stream Chat WebSocket events fire outside OpenTUI's render cycle.
+ // Track props driven by external callbacks and explicitly request a repaint.
+ createEffect(() => {
+ props.messages.length
+ props.online
+ renderer.requestRender()
+ })
+
+ return (
+
+ {/* Messages */}
+
+
+
+ No messages yet. Say hello!
+
+
+
+
+
+ Chat not available. Your instance may need to be provisioned or upgraded.
+
+
+
+
+ {(msg, index) => (
+ }>
+
+
+ )}
+
+
+
+ {/* Input area */}
+
+
+
+ {placeholder()}}>
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/opencode/src/kilocode/claw/client.ts b/packages/opencode/src/kilocode/claw/client.ts
new file mode 100644
index 00000000000..43a81b8d636
--- /dev/null
+++ b/packages/opencode/src/kilocode/claw/client.ts
@@ -0,0 +1,152 @@
+// kilocode_change - new file
+
+/**
+ * KiloClaw Stream Chat client wrapper
+ *
+ * Headless Stream Chat JS SDK integration for the TUI.
+ * Uses the same channel/credentials as the web dashboard's ChatTab.
+ *
+ * stream-chat is dynamically imported to avoid crashing Bun at module
+ * load time (follow-redirects uses Error.captureStackTrace in a way
+ * that is incompatible with Bun's runtime).
+ */
+
+import type { ChatCredentials, ChatMessage } from "./types"
+import { Log } from "@/util/log"
+
+const log = Log.create({ service: "claw-chat" })
+
+export type ClawChatClient = {
+ channel: any
+ disconnect: () => Promise
+ send: (text: string) => Promise
+ onMessage: (cb: (msg: ChatMessage) => void) => () => void
+ onMessageUpdated: (cb: (msg: ChatMessage) => void) => () => void
+ onPresence: (cb: (online: boolean) => void) => () => void
+}
+
+function botId(creds: ChatCredentials): string {
+ return `bot-${creds.channelId.replace(/^default-/, "")}`
+}
+
+function toMessage(raw: any, bot: string): ChatMessage {
+ return {
+ id: raw.id ?? "",
+ text: raw.text ?? "",
+ user: raw.user?.id ?? raw.user_id ?? "",
+ created: raw.created_at ? new Date(raw.created_at) : new Date(),
+ bot: (raw.user?.id ?? raw.user_id ?? "") === bot,
+ }
+}
+
+/**
+ * Apply Bun compatibility patches for stream-chat and its dependencies.
+ *
+ * 1. Error.captureStackTrace — follow-redirects passes a plain object with
+ * Error.prototype in its chain which crashes Bun.
+ * 2. net.Socket.prototype.destroy — Bun throws "First argument must be an
+ * Error object" when destroy() receives a non-Error. The ws library and
+ * stream-chat internals can trigger this during WebSocket lifecycle events.
+ *
+ * Both patches are applied once and remain active for the process lifetime.
+ */
+let patched = false
+function applyBunPatches() {
+ if (patched) return
+ patched = true
+
+ const orig = Error.captureStackTrace
+ Error.captureStackTrace = function safe(target: any, ctor?: Function) {
+ try {
+ return orig.call(Error, target, ctor)
+ } catch {
+ // Bun throws when target is not a real Error (e.g. follow-redirects
+ // CustomError which has Error.prototype in its chain but is not
+ // constructed via `new Error()`). Silently skip.
+ }
+ } as typeof Error.captureStackTrace
+
+ try {
+ const net = require("net")
+ const origDestroy = net.Socket.prototype.destroy
+ net.Socket.prototype.destroy = function (err: any, cb: any) {
+ if (err != null && !(err instanceof Error)) {
+ err = new Error(String(err))
+ }
+ return origDestroy.call(this, err, cb)
+ }
+ } catch {
+ // net module unavailable — skip socket patch
+ }
+}
+
+async function loadStreamChat() {
+ applyBunPatches()
+ const mod = await import("stream-chat")
+ return mod.StreamChat
+}
+
+export async function connect(creds: ChatCredentials): Promise {
+ log.info("loading stream-chat")
+ const StreamChat = await loadStreamChat()
+ log.info("getInstance", { key: creds.apiKey?.substring(0, 8) + "..." })
+ const client = StreamChat.getInstance(creds.apiKey)
+
+ log.info("connectUser", { user: creds.userId })
+ await client.connectUser({ id: creds.userId }, creds.userToken)
+
+ log.info("watching channel", { channel: creds.channelId })
+ const channel = client.channel("messaging", creds.channelId)
+ await channel.watch({ presence: true })
+
+ log.info("connected successfully")
+ const bot = botId(creds)
+
+ return {
+ channel,
+ async disconnect() {
+ await client.disconnectUser()
+ },
+ async send(text: string) {
+ await channel.sendMessage({ text })
+ },
+ onMessage(cb) {
+ const handler = (event: any) => {
+ if (event.message) cb(toMessage(event.message, bot))
+ }
+ channel.on("message.new", handler)
+ return () => channel.off("message.new", handler)
+ },
+ onMessageUpdated(cb) {
+ const handler = (event: any) => {
+ if (event.message) cb(toMessage(event.message, bot))
+ }
+ channel.on("message.updated", handler)
+ return () => channel.off("message.updated", handler)
+ },
+ onPresence(cb) {
+ const handler = (event: any) => {
+ if (event.user?.id === bot) {
+ cb(event.user.online ?? false)
+ }
+ }
+ client.on("user.presence.changed", handler)
+ return () => client.off("user.presence.changed", handler)
+ },
+ }
+}
+
+export async function history(channel: any, bot: string): Promise {
+ const state = channel.state.messages
+ return state.map((raw: any) => toMessage(raw, bot))
+}
+
+/**
+ * Read the bot's initial online status from channel member state.
+ * Mirrors the cloud's `useBotOnlineStatus` which reads
+ * `channel.state.members[botUserId]?.user?.online`.
+ */
+export function presence(channel: any, bot: string): boolean {
+ const member = channel.state.members?.[bot]
+ return !!member?.user?.online
+}
diff --git a/packages/opencode/src/kilocode/claw/hooks.ts b/packages/opencode/src/kilocode/claw/hooks.ts
new file mode 100644
index 00000000000..613d0396db4
--- /dev/null
+++ b/packages/opencode/src/kilocode/claw/hooks.ts
@@ -0,0 +1,141 @@
+// kilocode_change - new file
+
+/**
+ * KiloClaw SolidJS reactive helpers
+ *
+ * Provides reactive state management for polling instance status
+ * and managing the Stream Chat connection lifecycle.
+ */
+
+import { createSignal, onMount, onCleanup } from "solid-js"
+import type { ClawStatus, ChatCredentials, ChatMessage } from "./types"
+import { connect, history, presence, type ClawChatClient } from "./client"
+import { Log } from "@/util/log"
+
+const log = Log.create({ service: "claw-chat" })
+
+/**
+ * Poll the KiloClaw instance status every `interval` ms.
+ * Returns a reactive signal with the latest status.
+ */
+export function createClawStatus(sdk: any, interval = 10_000) {
+ const [status, setStatus] = createSignal(null)
+ const [error, setError] = createSignal(null)
+ const [loading, setLoading] = createSignal(true)
+
+ onMount(() => {
+ const poll = async () => {
+ const res = await sdk.client.kilo.claw.status().catch(() => null)
+ if (res?.data && !res.error) {
+ setStatus(res.data as ClawStatus)
+ setError(null)
+ } else if (res?.error) {
+ setError(typeof res.error === "string" ? res.error : (res.error?.error ?? "Unknown error"))
+ }
+ setLoading(false)
+ }
+ poll()
+ const timer = setInterval(poll, interval)
+ onCleanup(() => clearInterval(timer))
+ })
+
+ return { status, error, loading }
+}
+
+/**
+ * Fetch Stream Chat credentials and manage the chat connection.
+ * Returns reactive signals for messages, bot presence, and connection state.
+ */
+export function createClawChat(sdk: any) {
+ const [messages, setMessages] = createSignal([])
+ const [online, setOnline] = createSignal(false)
+ const [connected, setConnected] = createSignal(false)
+ const [error, setError] = createSignal(null)
+ const [loading, setLoading] = createSignal(true)
+
+ let chat: ClawChatClient | null = null
+
+ const send = async (text: string) => {
+ if (!chat) return
+ await chat.send(text)
+ }
+
+ onMount(async () => {
+ log.info("fetching credentials")
+ const res = await sdk.client.kilo.claw.chatCredentials().catch((e: any) => {
+ log.error("chatCredentials() threw", { error: e?.message ?? String(e) })
+ return null
+ })
+
+ log.info("credentials response", {
+ hasData: String(!!res?.data),
+ dataIsNull: String(res?.data === null),
+ error: res?.error ? String(res.error) : "none",
+ dataKeys: res?.data ? Object.keys(res.data).join(",") : "none",
+ })
+
+ if (!res?.data || res.error) {
+ setError(res?.data === null ? null : "Failed to fetch chat credentials")
+ setLoading(false)
+ return
+ }
+
+ const creds = res.data as ChatCredentials
+
+ try {
+ log.info("calling connect()")
+ chat = await connect(creds)
+ log.info("connect() succeeded")
+
+ // Load existing messages
+ const bot = `bot-${creds.channelId.replace(/^default-/, "")}`
+ const existing = await history(chat.channel, bot)
+ log.info("loaded history", { count: existing.length })
+ setMessages(existing)
+
+ // Read initial bot presence from channel member state
+ setOnline(presence(chat.channel, bot))
+
+ // Subscribe to new messages
+ const unsub = chat.onMessage((msg) => {
+ setMessages((prev) => [...prev, msg])
+ })
+
+ // Subscribe to message updates (bot streams token-by-token via message.updated)
+ const unsubUpdated = chat.onMessageUpdated((msg) => {
+ setMessages((prev) => {
+ const idx = prev.findIndex((m) => m.id === msg.id)
+ if (idx === -1) return [...prev, msg]
+ const next = [...prev]
+ next[idx] = msg
+ return next
+ })
+ })
+
+ // Subscribe to bot presence changes
+ const unsubPresence = chat.onPresence(setOnline)
+
+ setConnected(true)
+ setLoading(false)
+
+ onCleanup(async () => {
+ unsub()
+ unsubUpdated()
+ unsubPresence()
+ if (chat) await chat.disconnect()
+ chat = null
+ })
+ } catch (err: any) {
+ log.error("connect failed", {
+ error: err?.message ?? String(err),
+ name: err?.name,
+ code: err?.code,
+ stack: err?.stack?.split("\n").slice(0, 5).join("\n"),
+ })
+ setError(err?.message ?? "Failed to connect to chat")
+ setLoading(false)
+ }
+ })
+
+ return { messages, online, connected, error, loading, send }
+}
diff --git a/packages/opencode/src/kilocode/claw/sidebar.tsx b/packages/opencode/src/kilocode/claw/sidebar.tsx
new file mode 100644
index 00000000000..6ab0102d8bb
--- /dev/null
+++ b/packages/opencode/src/kilocode/claw/sidebar.tsx
@@ -0,0 +1,150 @@
+// kilocode_change - new file
+
+/**
+ * KiloClaw status sidebar
+ *
+ * Displays essential instance information: status, bot presence,
+ * region, sandbox ID, uptime, and version.
+ *
+ * Visual style mirrors the session sidebar (routes/session/sidebar.tsx).
+ */
+
+import { Show } from "solid-js"
+import { useTheme } from "@tui/context/theme"
+import type { ClawStatus } from "./types"
+
+function dot(status: string | null | undefined, theme: any): string {
+ if (!status) return theme.textMuted
+ if (status === "running") return theme.success
+ if (status === "starting" || status === "restarting") return theme.warning
+ if (status === "destroying") return theme.error
+ return theme.textMuted
+}
+
+function uptime(started: string | null | undefined): string {
+ if (!started) return "—"
+ const ms = Date.now() - new Date(started).getTime()
+ if (ms < 0) return "—"
+ const seconds = Math.floor(ms / 1000)
+ const minutes = Math.floor(seconds / 60)
+ const hours = Math.floor(minutes / 60)
+ const days = Math.floor(hours / 24)
+ if (days > 0) return `${days}d ${hours % 24}h`
+ if (hours > 0) return `${hours}h ${minutes % 60}m`
+ if (minutes > 0) return `${minutes}m`
+ return `${seconds}s`
+}
+
+export function ClawSidebar(props: {
+ status: ClawStatus | null
+ loading: boolean
+ error: string | null
+ online: boolean
+ connected: boolean
+ chatLoading: boolean
+ chatError: string | null
+}) {
+ const { theme } = useTheme()
+
+ return (
+
+
+
+
+
+ KiloClaw
+
+
+
+
+ Loading...
+
+
+
+ {props.error}
+
+
+
+
+
+ Instance
+
+
+
+ •
+
+
+ {(props.status!.status ?? "unknown").replace(/^./, (c) => c.toUpperCase())}{" "}
+
+ {props.status!.status === "running" ? uptime(props.status!.lastStartedAt) : ""}
+
+
+
+
+
+
+
+ Bot
+
+ Connecting...}>
+
+ {props.chatError}
+
+
+
+
+ •
+
+ {props.online ? "Online" : "Offline"}
+
+
+
+ Unavailable
+
+
+
+
+
+
+ Details
+
+
+ Region {props.status!.flyRegion?.toUpperCase() ?? "—"}
+
+
+ Version {props.status!.openclawVersion ?? "—"}
+
+ = 1}>
+
+ Channels {props.status!.channelCount}
+
+
+
+
+
+
+
+ No instance found.
+ Visit kilo.ai/claw
+ to set one up.
+
+
+
+
+
+
+
+ Esc back
+
+
+
+ )
+}
diff --git a/packages/opencode/src/kilocode/claw/types.ts b/packages/opencode/src/kilocode/claw/types.ts
new file mode 100644
index 00000000000..0e44d14a2ef
--- /dev/null
+++ b/packages/opencode/src/kilocode/claw/types.ts
@@ -0,0 +1,34 @@
+// kilocode_change - new file
+
+/**
+ * KiloClaw TUI types
+ *
+ * Types for the KiloClaw chat and dashboard feature in the TUI.
+ */
+
+export type ClawStatus = {
+ status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null
+ sandboxId?: string
+ flyRegion?: string
+ machineSize?: { cpus: number; memory_mb: number }
+ openclawVersion?: string | null
+ lastStartedAt?: string | null
+ lastStoppedAt?: string | null
+ channelCount?: number
+ secretCount?: number
+}
+
+export type ChatCredentials = {
+ apiKey: string
+ userId: string
+ userToken: string
+ channelId: string
+}
+
+export type ChatMessage = {
+ id: string
+ text: string
+ user: string
+ created: Date
+ bot: boolean
+}
diff --git a/packages/opencode/src/kilocode/claw/view.tsx b/packages/opencode/src/kilocode/claw/view.tsx
new file mode 100644
index 00000000000..7ea629f7298
--- /dev/null
+++ b/packages/opencode/src/kilocode/claw/view.tsx
@@ -0,0 +1,86 @@
+// kilocode_change - new file
+
+/**
+ * KiloClaw full-screen view
+ *
+ * Main layout component for the /kiloclaw route.
+ * Renders a chat panel on the left and a status sidebar on the right.
+ * Escape navigates back to the previous route.
+ */
+
+import { createMemo } from "solid-js"
+import { useKeyboard } from "@opentui/solid"
+import { useRoute } from "@tui/context/route"
+import { useSDK } from "@tui/context/sdk"
+import { useCommandDialog } from "@tui/component/dialog-command"
+import { Toast } from "@tui/ui/toast"
+import { ClawChat } from "./chat"
+import { ClawSidebar } from "./sidebar"
+import { createClawStatus, createClawChat } from "./hooks"
+
+export function KiloClawView() {
+ const route = useRoute()
+ const sdk = useSDK()
+ const command = useCommandDialog()
+
+ // Poll instance status
+ const { status, error: statusError, loading: statusLoading } = createClawStatus(sdk)
+
+ // Connect to chat
+ const chat = createClawChat(sdk)
+
+ // Determine if chat input should be disabled
+ const disabled = createMemo(() => {
+ const s = status()
+ return !s || s.status !== "running"
+ })
+
+ // Register escape to navigate back
+ useKeyboard((evt) => {
+ if (evt.name === "escape") {
+ route.back()
+ evt.preventDefault()
+ evt.stopPropagation()
+ }
+ })
+
+ // Register view-specific commands
+ command.register(() => [
+ {
+ value: "kiloclaw.back",
+ title: "Back",
+ category: "KiloClaw",
+ hidden: true,
+ keybind: "escape" as any,
+ onSelect: () => {
+ route.back()
+ },
+ },
+ ])
+
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/opencode/src/kilocode/components/dialog-claw-setup.tsx b/packages/opencode/src/kilocode/components/dialog-claw-setup.tsx
new file mode 100644
index 00000000000..145777d026f
--- /dev/null
+++ b/packages/opencode/src/kilocode/components/dialog-claw-setup.tsx
@@ -0,0 +1,66 @@
+// kilocode_change - new file
+
+/**
+ * KiloClaw Setup Dialog
+ *
+ * Shown when the user has no KiloClaw instance provisioned.
+ * Provides links to set up an instance and learn more.
+ */
+
+import { useKeyboard } from "@opentui/solid"
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "@tui/context/theme"
+import { useDialog } from "@tui/ui/dialog"
+import { Link } from "@tui/ui/link"
+
+export function DialogClawSetup() {
+ const { theme } = useTheme()
+ const dialog = useDialog()
+
+ useKeyboard((evt: any) => {
+ if (evt.name === "return") {
+ dialog.clear()
+ }
+ })
+
+ return (
+
+
+
+ KiloClaw
+
+ esc
+
+
+
+
+ Personal AI for everyday life
+
+
+
+ KiloClaw gives you a personal AI that reads email, manages your calendar, monitors your projects, and lives in
+ Telegram, Slack — whatever you already use.
+
+
+
+ No app to install. No new interface to learn. Just message it like a friend.
+
+
+
+
+ Learn more
+
+
+
+
+
+
+ {"🦀 "}
+
+ Try KiloClaw
+
+
+
+
+ )
+}
diff --git a/packages/opencode/src/kilocode/components/dialog-claw-upgrade.tsx b/packages/opencode/src/kilocode/components/dialog-claw-upgrade.tsx
new file mode 100644
index 00000000000..df07b48e139
--- /dev/null
+++ b/packages/opencode/src/kilocode/components/dialog-claw-upgrade.tsx
@@ -0,0 +1,54 @@
+// kilocode_change - new file
+
+/**
+ * KiloClaw Upgrade Dialog
+ *
+ * Shown when the user has an active KiloClaw instance but no chat credentials,
+ * indicating the instance was provisioned before chat was enabled and needs
+ * an upgrade to the latest version.
+ */
+
+import { useKeyboard } from "@opentui/solid"
+import { useTheme } from "@tui/context/theme"
+import { useDialog } from "@tui/ui/dialog"
+import { Link } from "@tui/ui/link"
+
+export function DialogClawUpgrade() {
+ const { theme } = useTheme()
+ const dialog = useDialog()
+
+ useKeyboard((evt: any) => {
+ if (evt.name === "return") {
+ dialog.clear()
+ }
+ })
+
+ return (
+
+
+
+ KiloClaw Chat requires an upgrade
+
+ esc
+
+
+
+
+ This instance was provisioned before chat was enabled.
+
+
+ Use the ↻ Upgrade to Latest button on the KiloClaw
+ dashboard to activate real-time chat with your bot.
+
+
+
+
+
+
+ Dashboard
+
+
+
+
+ )
+}
diff --git a/packages/opencode/src/kilocode/kilo-commands.tsx b/packages/opencode/src/kilocode/kilo-commands.tsx
index 6d725d2b0f2..f7c7d8a5580 100644
--- a/packages/opencode/src/kilocode/kilo-commands.tsx
+++ b/packages/opencode/src/kilocode/kilo-commands.tsx
@@ -7,12 +7,15 @@
import { createMemo } from "solid-js"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useSync } from "@tui/context/sync"
+import { useRoute } from "@tui/context/route"
import { useDialog } from "@tui/ui/dialog"
import { useToast } from "@tui/ui/toast"
import { DialogAlert } from "@tui/ui/dialog-alert"
import type { Organization } from "@kilocode/kilo-gateway"
import { DialogKiloTeamSelect } from "./components/dialog-kilo-team-select.js"
import { DialogKiloProfile } from "./components/dialog-kilo-profile.js"
+import { DialogClawSetup } from "./components/dialog-claw-setup.js"
+import { DialogClawUpgrade } from "./components/dialog-claw-upgrade.js"
// These types are OpenCode-internal and imported at runtime
type UseSDK = any
@@ -27,6 +30,7 @@ type SDK = any
export function registerKiloCommands(useSDK: () => UseSDK) {
const command = useCommandDialog()
const sync = useSync()
+ const route = useRoute()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
@@ -37,6 +41,40 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
})
command.register(() => [
+ // /kiloclaw command
+ {
+ value: "kilo.claw",
+ title: "KiloClaw",
+ description: "Open KiloClaw chat & dashboard",
+ category: "Kilo",
+ slash: { name: "kiloclaw", aliases: ["claw"] },
+ enabled: isKiloConnected(),
+ hidden: !isKiloConnected(),
+ onSelect: async () => {
+ // Check instance status first
+ const res = await sdk.client.kilo.claw.status().catch(() => null)
+
+ // No instance provisioned
+ if (!res?.data || !res.data.userId || res.error) {
+ dialog.replace(() => )
+ return
+ }
+
+ // Instance exists — check for chat credentials
+ const creds = await sdk.client.kilo.claw.chatCredentials().catch(() => null)
+
+ if (!creds?.data || creds.error) {
+ // Instance exists but no chat credentials — needs upgrade
+ dialog.replace(() => )
+ return
+ }
+
+ // Everything ready — navigate to full-screen chat view
+ route.navigate({ type: "kiloclaw" })
+ dialog.clear()
+ },
+ },
+
// /remote command
{
value: "remote.toggle",
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 640cc66fb0d..435ad006e24 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -51,6 +51,8 @@ import type {
GlobalEventResponses,
GlobalHealthResponses,
InstanceDisposeResponses,
+ KiloClawChatCredentialsResponses,
+ KiloClawStatusResponses,
KiloCloudSessionGetErrors,
KiloCloudSessionGetResponses,
KiloCloudSessionImportErrors,
@@ -3298,6 +3300,68 @@ export class Cloud extends HeyApiClient {
}
}
+export class Claw extends HeyApiClient {
+ /**
+ * Get KiloClaw instance status
+ *
+ * Fetch the user's KiloClaw instance status via the KiloClaw worker
+ */
+ public status(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/kilo/claw/status",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Get KiloClaw chat credentials
+ *
+ * Fetch Stream Chat credentials for the user's KiloClaw instance
+ */
+ public chatCredentials(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/kilo/claw/chat-credentials",
+ ...options,
+ ...params,
+ })
+ }
+}
+
export class Kilo extends HeyApiClient {
/**
* Get Kilo Gateway profile
@@ -3479,6 +3543,11 @@ export class Kilo extends HeyApiClient {
get cloud(): Cloud {
return (this._cloud ??= new Cloud({ client: this.client }))
}
+
+ private _claw?: Claw
+ get claw(): Claw {
+ return (this._claw ??= new Claw({ client: this.client }))
+ }
}
export class Find extends HeyApiClient {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index af5ae1d6b6d..c976d9afc0e 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -4844,6 +4844,47 @@ export type KiloCloudSessionImportResponses = {
200: unknown
}
+export type KiloClawStatusData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/kilo/claw/status"
+}
+
+export type KiloClawStatusResponses = {
+ /**
+ * Instance status
+ */
+ 200: unknown
+}
+
+export type KiloClawChatCredentialsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/kilo/claw/chat-credentials"
+}
+
+export type KiloClawChatCredentialsResponses = {
+ /**
+ * Stream Chat credentials or null
+ */
+ 200: {
+ apiKey: string
+ userId: string
+ userToken: string
+ channelId: string
+ } | null
+}
+
+export type KiloClawChatCredentialsResponse = KiloClawChatCredentialsResponses[keyof KiloClawChatCredentialsResponses]
+
export type KiloCloudSessionsData = {
body?: never
path?: never
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 8393d52605d..c595f5466d0 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -5042,6 +5042,15 @@
"isFree": {
"type": "boolean"
},
+ "ai_sdk_provider": {
+ "type": "string",
+ "enum": [
+ "anthropic",
+ "openai",
+ "openai-compatible",
+ "openrouter"
+ ]
+ },
"experimental": {
"type": "boolean"
},
@@ -5473,7 +5482,10 @@
"type": "boolean"
}
},
- "required": ["enabled", "connected"]
+ "required": [
+ "enabled",
+ "connected"
+ ]
}
}
}
@@ -5523,7 +5535,10 @@
"type": "boolean"
}
},
- "required": ["enabled", "connected"]
+ "required": [
+ "enabled",
+ "connected"
+ ]
}
}
}
@@ -5573,7 +5588,10 @@
"type": "boolean"
}
},
- "required": ["enabled", "connected"]
+ "required": [
+ "enabled",
+ "connected"
+ ]
}
}
}
@@ -6158,7 +6176,32 @@
},
"groups": {
"type": "array",
- "items": {}
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "prefixItems": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "fileRegex": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
}
}
}
@@ -6532,6 +6575,113 @@
]
}
},
+ "/kilo/claw/status": {
+ "get": {
+ "operationId": "kilo.claw.status",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "Get KiloClaw instance status",
+ "description": "Fetch the user's KiloClaw instance status via the KiloClaw worker",
+ "responses": {
+ "200": {
+ "description": "Instance status",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilo.claw.status({\n ...\n})"
+ }
+ ]
+ }
+ },
+ "/kilo/claw/chat-credentials": {
+ "get": {
+ "operationId": "kilo.claw.chatCredentials",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "Get KiloClaw chat credentials",
+ "description": "Fetch Stream Chat credentials for the user's KiloClaw instance",
+ "responses": {
+ "200": {
+ "description": "Stream Chat credentials or null",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "apiKey": {
+ "type": "string"
+ },
+ "userId": {
+ "type": "string"
+ },
+ "userToken": {
+ "type": "string"
+ },
+ "channelId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "apiKey",
+ "userId",
+ "userToken",
+ "channelId"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilo.claw.chatCredentials({\n ...\n})"
+ }
+ ]
+ }
+ },
"/kilo/cloud-sessions": {
"get": {
"operationId": "kilo.cloudSessions",
@@ -12659,6 +12809,15 @@
"isFree": {
"type": "boolean"
},
+ "ai_sdk_provider": {
+ "type": "string",
+ "enum": [
+ "anthropic",
+ "openai",
+ "openai-compatible",
+ "openrouter"
+ ]
+ },
"experimental": {
"type": "boolean"
},
@@ -13740,6 +13899,15 @@
},
"isFree": {
"type": "boolean"
+ },
+ "ai_sdk_provider": {
+ "type": "string",
+ "enum": [
+ "anthropic",
+ "openai",
+ "openai-compatible",
+ "openrouter"
+ ]
}
},
"required": [