Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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/remote-agent-manager-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Enable remote control from Agent Manager without disrupting concurrent sidebar sessions, and include all open Agent Manager sessions.
4 changes: 3 additions & 1 deletion packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.statsPoller.setEnabled(webviewView.visible)
this.statsPoller.setVisible(webviewView.visible)
}
this.focusSession(webviewView.visible ? this.currentSession?.id : undefined)
this.focusSession(webviewView.visible ? this.contextSessionID : undefined)
})
this.initializeConnection()
}
Expand Down Expand Up @@ -1481,6 +1481,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.stopCurrentSessionProcesses(session.id)
this.setCurrentSession(session)
this.contextSessionID = session.id
this.focusSession(session.id)
this.trackDirectory(session.id, workspaceDir)
this.trackedSessionIds.add(session.id)

Expand Down Expand Up @@ -2501,6 +2502,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.stopCurrentSessionProcesses(session.id)
this.setCurrentSession(session)
this.contextSessionID = session.id
this.focusSession(session.id)
this.trackDirectory(session.id, dir)
this.trackedSessionIds.add(session.id)
this.postMessage({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ export class AgentManagerProvider implements Disposable {
this.statsPoller.stop()
this.prBridge.poller.stop()
this.diffs.stop()
this.activeSessionId = undefined
this.connectionService.unregisterFocused("agent-manager")
this.connectionService.registerOpen("agent-manager", [])
this.panel = undefined
this.onVisibilityChange?.(false)
}
Expand Down
3 changes: 3 additions & 0 deletions packages/kilo-vscode/src/agent-manager/vscode-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { buildWebviewHtml } from "../utils"
import { openFileInEditor, getWorkspaceRoot } from "../review-utils"
import { TelemetryProxy, type TelemetryEventName } from "../services/telemetry"
import type { AutoApproveController } from "../commands/toggle-auto-approve"
import type { RemoteStatusService } from "../services/RemoteStatusService"

export class VscodeHost implements Host {
private diffVirtual: DiffVirtualProvider | undefined
Expand All @@ -24,6 +25,7 @@ export class VscodeHost implements Host {
private readonly extensionUri: vscode.Uri,
private readonly connectionService: KiloConnectionService,
private readonly context: vscode.ExtensionContext,
private readonly remoteService: RemoteStatusService,
) {}

setDiffVirtualProvider(provider: DiffVirtualProvider): void {
Expand Down Expand Up @@ -98,6 +100,7 @@ export class VscodeHost implements Host {
if (this.diffVirtual) {
provider.setDiffVirtualProvider(this.diffVirtual)
}
provider.setRemoteService(this.remoteService)
provider.attachToWebview(panel.webview, {
onBeforeMessage: opts.onBeforeMessage,
})
Expand Down
2 changes: 1 addition & 1 deletion packages/kilo-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(kiloClawProvider)

// Create Agent Manager provider for editor panel
const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context)
const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService)
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService)
agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible }))
agentManager = agentManagerProvider
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,50 @@
import { describe, expect, test } from "bun:test"
import { KiloConnectionService } from "./connection-service"

describe("KiloConnectionService viewed sessions", () => {
test("keeps Agent Manager sessions when sidebar focus changes during a flush", async () => {
const service = new KiloConnectionService({} as any)
const calls: Array<{ focused: string[]; open?: string[] }> = []
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
let active = 0
let max = 0

;(service as any).remoteService = { getState: () => ({ enabled: true }) }
;(service as any).client = {
session: {
viewed: async (input: { focused: string[]; open?: string[] }) => {
calls.push(input)
active += 1
max = Math.max(max, active)
if (calls.length === 1) await gate
active -= 1
},
},
}

service.registerFocused("agent-manager", "am-1")
service.registerOpen("agent-manager", ["am-1", "am-2"])
await Bun.sleep(175)
expect(calls).toEqual([{ focused: ["am-1"], open: ["am-2"] }])

service.registerFocused("sidebar", "side-1")
await Bun.sleep(175)
expect(calls).toHaveLength(1)

release()
await Bun.sleep(10)
expect(max).toBe(1)
expect(calls[1]).toEqual({ focused: ["am-1", "side-1"], open: ["am-2"] })

service.unregisterFocused("sidebar")
await Bun.sleep(175)
expect(calls[2]).toEqual({ focused: ["am-1"], open: ["am-2"] })
})
})

describe("KiloConnectionService drainPendingPrompts", () => {
test("ignores stale NotFoundError replies while draining permissions", async () => {
const service = new KiloConnectionService({} as any)
Expand Down
44 changes: 34 additions & 10 deletions packages/kilo-vscode/src/services/cli-backend/connection-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export class KiloConnectionService {
/** Provider key → all open (background) session IDs. */
private readonly opened: Map<string, string[]> = new Map()
private debounceTimer: ReturnType<typeof setTimeout> | null = null
private viewedSending = false
private viewedDirty = false
private unsubRemote: (() => void) | null = null

constructor(context: vscode.ExtensionContext) {
Expand Down Expand Up @@ -520,19 +522,40 @@ export class KiloConnectionService {
if (this.debounceTimer) clearTimeout(this.debounceTimer)
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null
const focus = new Set(this.focused.values())
const open = new Set<string>()
for (const ids of this.opened.values()) {
for (const id of ids) {
if (!focus.has(id)) open.add(id)
}
}
this.client?.session
.viewed({ focused: [...focus], open: [...open] })
.catch((err) => console.warn("[Kilo New] ConnectionService: viewed flush failed:", err))
this.sendViewed()
}, 150)
}

private sendViewed(): void {
if (!this.isRemoteEnabled()) {
this.viewedDirty = false
return
}
if (this.viewedSending) {
this.viewedDirty = true
return
}
if (!this.client) return

const focus = new Set(this.focused.values())
const open = new Set<string>()
for (const ids of this.opened.values()) {
for (const id of ids) {
if (!focus.has(id)) open.add(id)
}
}

this.viewedSending = true
this.viewedDirty = false
void this.client.session
.viewed({ focused: [...focus], open: [...open] })
.catch((err) => console.warn("[Kilo New] ConnectionService: viewed flush failed:", err))
.finally(() => {
this.viewedSending = false
if (this.viewedDirty) this.sendViewed()
})
}

/**
* Clean up everything: kill server, close SSE, clear listeners.
*/
Expand All @@ -558,6 +581,7 @@ export class KiloConnectionService {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
this.viewedDirty = false
this.unsubRemote?.()
this.unsubRemote = null
this.client = null
Expand Down
12 changes: 12 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ describe("Agent Manager Provider Messages", () => {
expect(body).toContain("await this.terminalRouter.dispose()")
expect(body).not.toContain("void this.terminalRouter.dispose()")
})

it("clears remote session registrations when the panel closes", () => {
const body = getMethodBody("attachPanel")
expect(body).toContain('this.connectionService.unregisterFocused("agent-manager")')
expect(body).toContain('this.connectionService.registerOpen("agent-manager", [])')
expect(body).toContain("this.activeSessionId = undefined")
})

it("reports all open Agent Manager sessions for remote control", () => {
const body = fs.readFileSync(TSX_FILE, "utf-8")
expect(body).toContain("reportRemoteSessions(vscode, localSessionIDs, managedSessions, isPending)")
})
})

describe("Agent Manager Model Picker", () => {
Expand Down
30 changes: 30 additions & 0 deletions packages/kilo-vscode/tests/unit/extension-arch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const PKG_JSON_FILE = path.join(ROOT, "package.json")
const SRC_DIR = path.join(ROOT, "src")
const EXTENSION_FILE = path.join(ROOT, "src/extension.ts")
const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
const VSCODE_HOST_FILE = path.join(ROOT, "src/agent-manager/vscode-host.ts")

function sliceBlock(source: string, start: number): string {
const open = source.indexOf("{", start)
Expand Down Expand Up @@ -187,6 +188,35 @@ describe("Extension — KiloProvider handler wiring", () => {
// it silently no-op'd, leaving the UI stuck.
// ---------------------------------------------------------------------------

describe("Extension — Agent Manager remote wiring", () => {
const ext = fs.readFileSync(EXTENSION_FILE, "utf-8")
const host = fs.readFileSync(VSCODE_HOST_FILE, "utf-8")

it("passes the shared remote service to Agent Manager", () => {
expect(ext).toContain("new VscodeHost(context.extensionUri, connectionService, context, remoteService)")
})

it("wires the remote service before attaching the Agent Manager webview", () => {
const remote = host.indexOf("provider.setRemoteService(this.remoteService)")
const attach = host.indexOf("provider.attachToWebview")
expect(remote).toBeGreaterThan(-1)
expect(attach).toBeGreaterThan(-1)
expect(remote).toBeLessThan(attach)
})
})

describe("KiloProvider — remote focus lifecycle", () => {
const provider = fs.readFileSync(KILO_PROVIDER_FILE, "utf-8")

it("registers newly created sessions and uses the synchronous session ID", () => {
const create = sliceBlock(provider, provider.indexOf("private async handleCreateSession"))
const resolve = sliceBlock(provider, provider.indexOf("private async resolveSession"))
expect(create).toContain("this.focusSession(session.id)")
expect(resolve).toContain("this.focusSession(session.id)")
expect(provider).toContain("this.focusSession(webviewView.visible ? this.contextSessionID : undefined)")
})
})

describe("KiloProvider — continueInWorktree error fallback", () => {
const helper = fs.readFileSync(path.join(ROOT, "src/kilo-provider/continue-worktree.ts"), "utf-8")

Expand Down
25 changes: 25 additions & 0 deletions packages/kilo-vscode/tests/unit/navigate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
restoreLocalSessions,
reconcileLocalSessions,
filterUnassignedSessions,
remoteSessions,
LOCAL,
} from "../../webview-ui/agent-manager/navigate"

Expand Down Expand Up @@ -396,6 +397,30 @@ describe("restoreLocalSessions", () => {
})
})

describe("remoteSessions", () => {
const pending = (id: string) => id.startsWith("pending:")

it("returns every real tab without collapsing sessions in the same worktree", () => {
const result = remoteSessions(
["local-1", "pending:1", "shared"],
[
{ id: "shared", worktreeId: "wt-1" },
{ id: "worktree-1", worktreeId: "wt-1" },
{ id: "worktree-2", worktreeId: "wt-1" },
{ id: "worktree-3", worktreeId: "wt-2" },
{ id: "closed-local", worktreeId: null },
],
pending,
)

expect(result).toEqual(["local-1", "shared", "worktree-1", "worktree-2", "worktree-3"])
})

it("returns an empty list without open sessions", () => {
expect(remoteSessions([], [], pending)).toEqual([])
})
})

describe("reconcileLocalSessions", () => {
const isPending = (id: string) => id.startsWith("pending-")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import {
} from "./navigate"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
import { createTabOrderSync } from "./tab-order-sync"
import { reportRemoteSessions } from "./remote-sessions"
import { ConstrainDragYAxis } from "./sortable-tab"
import { isTerminalTabId, createTerminalState, createTerminalHandlers, createTerminalMessageHandler } from "./terminal"
import { renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
Expand Down Expand Up @@ -544,6 +545,7 @@ const AgentManagerContent: Component = () => {
)

const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
reportRemoteSessions(vscode, localSessionIDs, managedSessions, isPending)

// Drag-and-drop state for tab reordering
const [draggingTab, setDraggingTab] = createSignal<string | undefined>()
Expand Down
13 changes: 13 additions & 0 deletions packages/kilo-vscode/webview-ui/agent-manager/navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@ export function restoreLocalSessions(
return changed ? merged : undefined
}

export function remoteSessions(
local: string[],
managed: { id: string; worktreeId: string | null }[],
pending: (id: string) => boolean,
): string[] {
return [
...new Set([
...local.filter((id) => !pending(id)),
...managed.filter((session) => session.worktreeId).map((session) => session.id),
]),
]
}

export function reconcileLocalSessions(
current: string[],
loaded: string[],
Expand Down
22 changes: 22 additions & 0 deletions packages/kilo-vscode/webview-ui/agent-manager/remote-sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createEffect, type Accessor } from "solid-js"
import { remoteSessions } from "./navigate"

type Bridge = {
postMessage(message: { type: "agentManager.openSessions"; sessionIDs: string[] }): void
}

type Managed = { id: string; worktreeId: string | null }

export function reportRemoteSessions(
vscode: Bridge,
local: Accessor<string[]>,
managed: Accessor<Managed[]>,
pending: (id: string) => boolean,
): void {
createEffect(() => {
vscode.postMessage({
type: "agentManager.openSessions",
sessionIDs: remoteSessions(local(), managed(), pending),
})
})
}
10 changes: 3 additions & 7 deletions packages/opencode/src/kilo-sessions/kilo-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export namespace KiloSessions {
})

const remoteEnabled = process.env["KILO_REMOTE"] === "1"
let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender; heartbeat: () => Promise<void> } | undefined
let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined
let enabling: Promise<void> | undefined
let remoteSeq = 0
const focused = new Set<string>()
Expand Down Expand Up @@ -452,17 +452,13 @@ export namespace KiloSessions {
log,
})

const heartbeat = async () => {
conn.send({ type: "heartbeat", ...(await getSessions()) })
}

if (seq !== remoteSeq) {
sender.dispose()
conn.close()
return
}

remote = { conn, sender, heartbeat }
remote = { conn, sender }
log.info("remote connection enabled", { connected: conn.connected })
Telemetry.trackRemoteConnectionOpened()
void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: true, connected: conn.connected })
Expand Down Expand Up @@ -509,7 +505,7 @@ export namespace KiloSessions {
for (const id of input.open ?? []) {
opened.add(id)
}
if (remote) void remote.heartbeat().catch((err) => log.warn("heartbeat failed", { error: String(err) }))
if (remote) void remote.conn.heartbeat().catch((err) => log.warn("heartbeat failed", { error: String(err) }))
}

export async function create(sessionId: string) {
Expand Down
Loading
Loading