Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-kilo-swarm-board.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Add Kilo Swarm support: a toggle in Agent Behavior settings, readable board messages in the transcript, and a board viewer opened from the session header or the session menu that pages through messages, jumps to a participating subagent, and can reset the board.
4 changes: 2 additions & 2 deletions packages/kilo-docs/pages/getting-started/settings/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ See [Sandboxing](/docs/getting-started/settings/sandboxing) for setup instructio

Kilo Swarm lets a main session and its task descendants, including nested subagents, exchange messages on a shared board. It uses the existing Task tool, not a separate agent runtime. The board is not shared with unrelated sessions, even in the same repository or worktree.

Kilo Swarm is on by default. Turn it off in the VS Code **Agent Behaviour** settings, or set `shared_agent_board` to `false` in `kilo.jsonc`.
Kilo Swarm is on by default. Turn it off in the VS Code or JetBrains **Agent Behaviour** settings, or set `shared_agent_board` to `false` in `kilo.jsonc`.

Use it when agents can benefit from discoveries during work:

Expand All @@ -230,7 +230,7 @@ Straightforward tasks can stay solo. Enabling the board does not mean agents are

All participants can read the board history, including messages addressed to others. Recipient selection is not a privacy boundary. Peer messages do not grant user approval or change permissions; `HOLD` and `VETO` are advisory, not controls that pause or cancel work.

When a main session has board messages, open the **Board** icon in its task header to read them, refresh them, or reset the board. Only the owning top-level session can view or reset its board; child sessions and cloud sessions cannot. Reset clears visible messages only and does not stop agents or clear conversations. See [Kilo Swarm communication](/docs/automate/agent-manager#kilo-swarm-communication) for the board dialog, ownership rules, and recipient-state warnings.
When a main session has board messages, open the **Board** icon in its task header (VS Code) or session header (JetBrains, which also offers a **View Kilo Swarm** session menu action) to read them, refresh them, or reset the board. Only the owning top-level session can view or reset its board; child sessions and cloud sessions cannot. Reset clears visible messages only and does not stop agents or clear conversations. See [Kilo Swarm communication](/docs/automate/agent-manager#kilo-swarm-communication) for the board dialog, ownership rules, and recipient-state warnings.

## Experimental Features

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import ai.kilocode.jetbrains.api.client.DefaultApi
import ai.kilocode.jetbrains.api.model.GlobalSession
import ai.kilocode.jetbrains.api.model.SessionStatus
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.SessionBoardDto
import ai.kilocode.rpc.dto.SessionChangeDto
import ai.kilocode.rpc.dto.SessionChangeKindDto
import ai.kilocode.rpc.dto.SessionDto
Expand Down Expand Up @@ -246,6 +247,85 @@ class KiloBackendSessionManager(
}
}

/**
* Load the shared agent board for root session [id] via
* `GET /kilocode/session/{id}/board`.
*
* Uses raw HTTP because these routes are newer than the generated client built from the
* pinned CLI release (see [ai.kilocode.jetbrains.api.client.DefaultApi]). Throws when [id]
* is not the board's root session, matching the ownership guard in
* `packages/kilo-vscode/src/kilo-provider/session-board.ts`.
*/
fun sessionBoard(id: String, dir: String, before: String?, limit: Int?): SessionBoardDto {
val h = http ?: throw IllegalStateException("Session manager not started")
val url = base ?: throw IllegalStateException("Session manager not started")
val target = url.toHttpUrl().newBuilder()
.addPathSegment("kilocode")
.addPathSegment("session")
.addPathSegment(id)
.addPathSegment("board")
.addQueryParameter("directory", dir)
.apply {
if (!before.isNullOrBlank()) addQueryParameter("before", before)
if (limit != null) addQueryParameter("limit", limit.toString())
}
.build()
log.info("Loading session board: GET $target")
val request = Request.Builder().url(target).get().build()
h.newCall(request).execute().use { response ->
val raw = response.body?.string()
if (!response.isSuccessful) {
log.warn("Session board load failed: HTTP ${response.code}, body=$raw")
throw RuntimeException("Session board load failed: HTTP ${response.code} — $raw")
}
val board = KiloCliDataParser.parseSessionBoard(raw!!)
if (board.ownerSessionID != id) {
log.warn("Session board ownership mismatch: requested=$id owner=${board.ownerSessionID}")
throw IllegalStateException("Session $id is not the board's root session")
}
return board
}
}

/**
* Clear the shared agent board for root session [id] via
* `POST /kilocode/session/{id}/board/reset`, guarded by [revision]. Returns null on HTTP 409
* (the board changed since [revision] was read) so the caller reloads instead of retrying
* blindly; throws on any other failure or ownership mismatch.
*/
fun resetSessionBoard(id: String, dir: String, revision: Int): SessionBoardDto? {
val h = http ?: throw IllegalStateException("Session manager not started")
val url = base ?: throw IllegalStateException("Session manager not started")
val target = url.toHttpUrl().newBuilder()
.addPathSegment("kilocode")
.addPathSegment("session")
.addPathSegment(id)
.addPathSegment("board")
.addPathSegment("reset")
.addQueryParameter("directory", dir)
.build()
log.info("Resetting session board: POST $target revision=$revision")
val body = KiloCliDataParser.buildResetSessionBoardJson(revision).toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(target).post(body).build()
h.newCall(request).execute().use { response ->
val raw = response.body?.string()
if (response.code == 409) {
log.info("Session board reset conflict: HTTP 409, body=$raw")
return null
}
if (!response.isSuccessful) {
log.warn("Session board reset failed: HTTP ${response.code}, body=$raw")
throw RuntimeException("Session board reset failed: HTTP ${response.code} — $raw")
}
val board = KiloCliDataParser.parseSessionBoard(raw!!)
if (board.ownerSessionID != id) {
log.warn("Session board ownership mismatch: requested=$id owner=${board.ownerSessionID}")
throw IllegalStateException("Session $id is not the board's root session")
}
return board
}
}

fun get(id: String, dir: String): SessionDto {
val all = requireClient().sessionList(directory = dir)
val raw = all.firstOrNull { it.id == id }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import ai.kilocode.backend.workspace.ModelTerminalBenchInfo
import ai.kilocode.backend.workspace.ProviderData
import ai.kilocode.backend.workspace.ProviderInfo
import ai.kilocode.rpc.dto.AgentConfigDto
import ai.kilocode.rpc.dto.BoardMessageDto
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.CloudSessionListDto
Expand Down Expand Up @@ -69,6 +70,7 @@ import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionChangeDto
import ai.kilocode.rpc.dto.SessionChangeKindDto
import ai.kilocode.rpc.dto.SessionBoardDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionRevertDto
import ai.kilocode.rpc.dto.SessionShareDto
Expand Down Expand Up @@ -412,6 +414,42 @@ object KiloCliDataParser {
return parseSessionObject(obj)
}

/**
* Parse a shared agent board response
* (`GET`/`POST /kilocode/session/{id}/board[/reset]`) into [SessionBoardDto]. A message row
* missing `id`/`from`/`to`/`type`/`body` is dropped rather than failing the whole board, so one
* malformed row does not hide the rest.
*/
fun parseSessionBoard(raw: String): SessionBoardDto {
val obj = json.parseToJsonElement(raw).jsonObject
val messages = obj["messages"].arr().orEmpty().mapNotNull { elem ->
val row = elem.obj() ?: return@mapNotNull null
val id = row.str("id") ?: return@mapNotNull null
val from = row.str("from") ?: return@mapNotNull null
val to = row.str("to") ?: return@mapNotNull null
val type = row.str("type") ?: return@mapNotNull null
val body = row.str("body") ?: return@mapNotNull null
BoardMessageDto(
id = id,
timestamp = row.long("timestamp") ?: 0L,
from = from,
to = to,
fromLabel = row.str("fromLabel"),
toLabel = row.str("toLabel"),
type = type,
body = body,
reply_to = row.str("reply_to"),
)
}
return SessionBoardDto(
ownerSessionID = obj.str("ownerSessionID").orEmpty(),
revision = obj.long("revision")?.safeInt() ?: 0,
messages = messages,
cursor = obj.str("cursor"),
hasMore = obj.bool("hasMore"),
)
}

/**
* Parse message history response (`GET /session/{id}/message`)
* into a list of messages with their parts.
Expand Down Expand Up @@ -560,6 +598,7 @@ object KiloCliDataParser {
mcp = parseMcpConfig(obj["mcp"].obj()),
agent = parseAgentConfig(obj["agent"].obj()),
permission = parsePermissionConfig(obj["permission"].obj()),
shared_agent_board = runCatching { obj.flagOrNull("shared_agent_board") }.getOrNull(),
)
}.getOrDefault(ConfigDto())

Expand Down Expand Up @@ -888,6 +927,9 @@ object KiloCliDataParser {
/** Body for `POST /session/{id}/fork`. A whole-session fork sends no body at all; see the caller. */
fun buildForkJson(messageId: String): String = """{"messageID":${escape(messageId)}}"""

/** Body for `POST /kilocode/session/{id}/board/reset`. */
fun buildResetSessionBoardJson(revision: Int): String = """{"revision":$revision}"""

/**
* Build the JSON body for `POST /session/{id}/summarize`.
*/
Expand Down Expand Up @@ -986,6 +1028,8 @@ object KiloCliDataParser {
val permission = patch.permission
if (permission != null) put("permission", buildPermission(permission))

if (patch.shared_agent_board != null) put("shared_agent_board", patch.shared_agent_board)

if (patch.agents.isNotEmpty()) {
put("agent", buildJsonObject {
for ((name, agent) in patch.agents) put(name, buildJsonObject {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionBoardDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionActivityDto
import ai.kilocode.rpc.dto.SessionChangeDto
Expand Down Expand Up @@ -326,6 +327,14 @@ class KiloSessionRpcApiImpl internal constructor(
override suspend fun pendingQuestions(directory: String): List<QuestionRequestDto> =
ready { chat.pendingQuestions(directory) }

// ------ shared agent board ------

override suspend fun sessionBoard(sessionID: String, directory: String, before: String?, limit: Int?): SessionBoardDto =
ready { withContext(Dispatchers.IO) { sessions.sessionBoard(sessionID, directory, before, limit) } }

override suspend fun resetSessionBoard(sessionID: String, directory: String, revision: Int): SessionBoardDto? =
ready { withContext(Dispatchers.IO) { sessions.resetSessionBoard(sessionID, directory, revision) } }

private suspend fun <T> ready(block: suspend () -> T): T {
app.requireReady()
return block()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,25 @@ class KiloBackendAppServiceTest {
assertEquals(false, svc.config?.compaction?.prune)
}

@Test
fun `update config patches shared_agent_board and reloads`() = runBlocking {
val svc = create()
svc.connect()
ready(svc)

val state = svc.updateConfig(ConfigPatchDto(
shared_agent_board = true,
))

assertEquals(
"{\"shared_agent_board\":true}",
mock.lastConfigPatchBody,
)
val cfg = appStateDto(state).config
assertEquals(true, cfg?.shared_agent_board)
assertEquals(true, svc.config?.shared_agent_board)
}

@Test
fun `ready dto maps model config`() = runBlocking {
mock.config = """{"model":"openai/gpt","agent":{"plan":{"model":"anthropic/claude","variant":"high"}}}"""
Expand Down
Loading
Loading