Merge branch 'main' into kirillk/jetbrains-2

This commit is contained in:
Kirill Kalishev
2026-04-09 13:11:26 -04:00
committed by GitHub
131 changed files with 3918 additions and 246 deletions
+3 -1
View File
@@ -23,7 +23,7 @@ on:
required: false
type: string
pre_release:
description: "Publish as pre-release (VS Code marketplace)"
description: "Publish as pre-release (VS Code marketplace + npm rc channel)"
required: false
type: boolean
default: false
@@ -58,6 +58,7 @@ jobs:
GH_REPO: ${{ github.repository }}
KILO_BUMP: ${{ inputs.bump }}
KILO_VERSION: ${{ inputs.version }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
outputs:
@@ -83,6 +84,7 @@ jobs:
env:
KILO_VERSION: ${{ needs.version.outputs.version }}
KILO_RELEASE: ${{ needs.version.outputs.release }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
@@ -201,6 +201,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
cleanup = undefined
}
lastScrollTop = undefined
scroll = el
if (!el) return
+11
View File
@@ -50,6 +50,17 @@
],
"main": "./dist/extension.js",
"contributes": {
"taskDefinitions": [
{
"type": "kilo-worktree-setup",
"properties": {
"script": {
"type": "string",
"description": "The setup script command to execute"
}
}
}
],
"viewsContainers": {
"activitybar": [
{
+86 -19
View File
@@ -36,6 +36,7 @@ import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { getWorkspaceRoot } from "./review-utils"
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
import type { RemoteStatusService } from "./services/RemoteStatusService"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { retry } from "./services/cli-backend/retry"
@@ -112,6 +113,7 @@ const mapAgent = (a: Agent) => ({
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
public static readonly viewType = "kilo-code.SidebarProvider"
private readonly instanceId = crypto.randomUUID()
private webview: vscode.Webview | null = null
private currentSession: Session | null = null
@@ -145,6 +147,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private cachedNotificationsMessage: unknown = null
private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = []
private readyResolvers: (() => void)[] = []
private promptRecoveryQueued = false
private promptRecovery: Promise<void> | null = null
private trackedSessionIds: Set<string> = new Set()
private syncedChildSessions: Set<string> = new Set()
/** Tracks the latest status for each session, used to warn before destructive config operations. */
@@ -173,6 +177,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private unsubscribeDirectoryProvider: (() => void) | null = null
private initConnectionPromise: Promise<void> | null = null
private webviewMessageDisposable: vscode.Disposable | null = null
private viewStateDisposable: vscode.Disposable | null = null
private visibilityDisposable: vscode.Disposable | null = null
/** Lazily initialized ignore controller for .kilocodeignore filtering */
private ignoreController: FileIgnoreController | null = null
@@ -198,6 +204,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
| null = null
private diffVirtualProvider: import("./DiffVirtualProvider").DiffVirtualProvider | undefined
private remoteService: RemoteStatusService | null = null
private unsubscribeRemote: (() => void) | null = null
constructor(
private readonly extensionUri: vscode.Uri,
@@ -211,6 +219,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
TelemetryProxy.getInstance().setProvider(this)
}
setRemoteService(service: RemoteStatusService): void {
this.remoteService = service
this.unsubscribeRemote = service.onChange(() => this.sendRemoteStatus())
}
private sendRemoteStatus(): void {
const s = this.remoteService?.getState()
if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected })
}
private focusSession(id?: string): void {
if (id) this.connectionService.registerFocused(this.instanceId, id)
else this.connectionService.unregisterFocused(this.instanceId)
}
public setProjectDirectory(directory: string | null): void {
if (this.projectDirectory === directory) return
this.projectDirectory = directory
@@ -324,6 +345,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// authoritative and reconciliation risks race-resetting busy sessions.
const reconcile = this.sessionStatusMap.size === 0
void this.seedSessionStatusMap(reconcile)
this.sendRemoteStatus()
}
// legacy-migration start
@@ -354,20 +377,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
localResourceRoots: [this.extensionUri],
}
// Set HTML content
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview)
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(webviewView.webview)
// Track sidebar visibility for keybinding when-clauses and stats polling
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
webviewView.onDidChangeVisibility(() => {
this.visibilityDisposable?.dispose()
this.visibilityDisposable = webviewView.onDidChangeVisibility(() => {
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
this.statsPoller?.setEnabled(webviewView.visible)
this.focusSession(webviewView.visible ? this.currentSession?.id : undefined)
})
// Initialize connection to CLI backend
this.initializeConnection()
}
@@ -386,9 +405,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
panel.webview.html = this._getHtmlForWebview(panel.webview)
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(panel.webview)
this.viewStateDisposable?.dispose()
this.viewStateDisposable = panel.onDidChangeViewState(() =>
this.focusSession(panel.active ? this.currentSession?.id : undefined),
)
this.initializeConnection()
}
@@ -444,6 +465,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
void this.handleLoadSessions()
}
/** Recover permission/question prompts after sessions and directories are tracked. */
public recoverPendingPrompts(): void {
this.promptRecoveryQueued = true
if (!this.isWebviewReady) return
if (!this.client) return
if (this.promptRecovery) return
this.promptRecovery = this.flushPendingPrompts().finally(() => {
this.promptRecovery = null
if (this.promptRecoveryQueued && this.isWebviewReady && this.client) this.recoverPendingPrompts()
})
}
private async flushPendingPrompts(): Promise<void> {
while (this.promptRecoveryQueued && this.isWebviewReady) {
if (!this.client) return
this.promptRecoveryQueued = false
await Promise.all([
fetchAndSendPendingPermissions(this.permissionCtx),
fetchAndSendPendingQuestions(this.questionCtx),
])
}
}
public openCloudSession(sessionId: string): void {
this.postMessage({ type: "openCloudSession", sessionId })
}
@@ -499,6 +544,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.isWebviewReady = true
await this.syncWebviewState("webviewReady")
this.flushPendingReviewComments()
this.recoverPendingPrompts()
this.readyResolvers.splice(0).forEach((r) => r())
break
case "sendMessage": {
@@ -582,6 +628,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "clearSession":
this.contextSessionID = this.currentSession?.id ?? this.contextSessionID
this.currentSession = null
this.focusSession()
break
case "loadMessages":
// Don't await: allow parallel loads so rapid session switching
@@ -817,6 +864,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "renameSession":
await this.handleRenameSession(message.sessionID, message.title)
break
case "toggleRemote":
case "setRemoteEnabled":
case "requestRemoteStatus":
this.remoteService
?.handleMessage(message.type, message.enabled)
.then((s) => {
if (s) this.sendRemoteStatus()
})
.catch((err) => console.error("[Kilo New] remote message failed:", err))
break
case "updateSetting":
await this.handleUpdateSetting(message.key, message.value)
break
@@ -1048,6 +1105,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Subscribe to SSE events for this webview (filtered by tracked sessions)
this.unsubscribeEvent = this.connectionService.onEventFiltered(
(event) => {
// Remote status events are global and should always pass through
if (event.type === "kilo-sessions.remote-status-changed") return true
const sessionId = this.connectionService.resolveEventSessionId(event)
// message.part.updated and message.part.delta are always session-scoped; drop if session unknown.
@@ -1090,8 +1149,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
await this.syncWebviewState("sse-connected")
await this.flushPendingSessionRefresh("sse-connected")
await fetchAndSendPendingPermissions(this.permissionCtx)
await fetchAndSendPendingQuestions(this.questionCtx)
this.recoverPendingPrompts()
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error)
this.postMessage({
@@ -1166,6 +1224,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await this.syncWebviewState("initializeConnection")
await this.flushPendingSessionRefresh("initializeConnection")
this.recoverPendingPrompts()
// Fetch providers, agents, skills, config, notifications, and session statuses in parallel
await Promise.all([
@@ -1244,6 +1303,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async handleLoadMessages(sessionID: string): Promise<void> {
// Track the session so we receive its SSE events
this.trackedSessionIds.add(sessionID)
this.focusSession(sessionID)
this.contextSessionID = sessionID
if (!this.client) {
@@ -1329,9 +1389,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
messages,
})
// Recover any permission.asked events that were missed while the webview
// was loading or during an SSE reconnection (fire-and-forget).
void fetchAndSendPendingPermissions(this.permissionCtx)
// Recover any prompts missed while the webview was loading or during an SSE reconnection.
this.recoverPendingPrompts()
} catch (error) {
// Silently ignore aborted requests — the user switched to a different session
if (abort.signal.aborted) return
@@ -1388,11 +1447,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
messages,
})
// Recover any missed permission/question prompts emitted by the child before
// we started tracking it. Both run fire-and-forget after messagesLoaded so
// the webview isn't blocked.
void fetchAndSendPendingPermissions(this.permissionCtx)
void fetchAndSendPendingQuestions(this.questionCtx)
// Recover any prompts emitted by the child before we started tracking it.
this.recoverPendingPrompts()
} catch (err) {
this.syncedChildSessions.delete(sessionID)
console.error("[Kilo New] KiloProvider: Failed to sync child session:", err)
@@ -2822,6 +2878,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Filters events by project ID and tracked session IDs so each webview only sees its own sessions.
*/
private handleEvent(event: Event): void {
if (event.type === "kilo-sessions.remote-status-changed") {
this.remoteService?.updateFromEvent({ enabled: event.properties.enabled, connected: event.properties.connected })
return
}
// Drop session events from other projects before any tracking logic.
// This must come first: the trackedSessionIds guard below would otherwise
// let a foreign session through if it was accidentally tracked.
@@ -3233,6 +3294,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Does NOT kill the server — that's the connection service's job.
*/
dispose(): void {
this.unsubscribeRemote?.()
this.focusSession()
this.statsPoller?.stop()
this.statsGitOps?.dispose()
this.unsubscribeEvent?.()
@@ -3244,7 +3307,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.unsubscribeMigrationComplete?.()
this.unsubscribeClearPendingPrompts?.()
this.unsubscribeDirectoryProvider?.()
this.viewStateDisposable?.dispose()
this.visibilityDisposable?.dispose()
this.webviewMessageDisposable?.dispose()
this.isWebviewReady = false
this.promptRecoveryQueued = false
this.trackedSessionIds.clear()
this.syncedChildSessions.clear()
this.sessionDirectories.clear()
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
import { KiloProvider } from "./KiloProvider"
import { resolvePanelProjectDirectory } from "./project-directory"
import type { KiloConnectionService } from "./services/cli-backend"
import type { RemoteStatusService } from "./services/RemoteStatusService"
type PanelView = "settings" | "profile" | "marketplace"
@@ -26,6 +27,7 @@ export class SettingsEditorProvider implements vscode.Disposable {
private panels = new Map<PanelView, vscode.WebviewPanel>()
private providers = new Map<PanelView, KiloProvider>()
private tabs = new Map<PanelView, string>()
private remoteService: RemoteStatusService | null = null
constructor(
private readonly extensionUri: vscode.Uri,
@@ -101,6 +103,9 @@ export class SettingsEditorProvider implements vscode.Disposable {
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
projectDirectory,
})
if (this.remoteService) {
provider.setRemoteService(this.remoteService)
}
provider.resolveWebviewPanel(panel)
// Listen for closePanel from the webview (back button in panel mode)
@@ -144,6 +149,14 @@ export class SettingsEditorProvider implements vscode.Disposable {
})
}
setRemoteService(service: RemoteStatusService): void {
this.remoteService = service
// Apply to any existing providers
for (const [, provider] of this.providers) {
provider.setRemoteService(service)
}
}
dispose(): void {
for (const [, panel] of this.panels) {
panel.dispose()
@@ -25,6 +25,7 @@ import { continueInWorktree } from "./continue-in-worktree"
import { shouldStopDiffPolling } from "./delete-worktree"
import { buildKeybindingMap } from "./format-keybinding"
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
import { Semaphore } from "./semaphore"
import { PLATFORM } from "./constants"
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils"
@@ -76,11 +77,13 @@ export class AgentManagerProvider implements Disposable {
(msg) => this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
createTerminalHost(),
)
this.gitOps = new GitOps({ log: (...args) => this.log(...args) })
const semaphore = new Semaphore(3)
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore })
this.statsPoller = new GitStatsPoller({
getWorktrees: () => this.state?.getWorktrees() ?? [],
getWorkspaceRoot: () => this.getRoot(),
getClient: () => this.connectionService.getClient(),
semaphore,
onStats: (stats) => {
const msg = { type: "agentManager.worktreeStats" as const, stats }
this.cachedWorktreeStats = msg
@@ -105,6 +108,7 @@ export class AgentManagerProvider implements Disposable {
hasPersistedPR: (id: string) => !!this.state?.getWorktree(id)?.prNumber,
openExternal: (u) => this.host.openExternal(u),
log: (...a) => this.log(...a),
semaphore,
})
}
@@ -197,24 +201,25 @@ export class AgentManagerProvider implements Disposable {
this.host.refreshGit()
}
// Do not auto-remove stale worktrees on load.
// Presence checks run in the shared poller and require explicit user cleanup.
// Register all worktree sessions with the session provider
for (const worktree of state.getWorktrees()) {
for (const session of state.getSessions(worktree.id)) {
this.panel?.sessions.setSessionDirectory(session.id, worktree.path)
this.panel?.sessions.trackSession(session.id)
for (const wt of state.getWorktrees()) {
for (const s of state.getSessions(wt.id)) {
this.panel?.sessions.setSessionDirectory(s.id, wt.path)
this.panel?.sessions.trackSession(s.id)
}
}
// Push full state to webview
for (const s of state.getSessions()) if (!s.worktreeId) this.panel?.sessions.trackSession(s.id)
this.pushState()
// Refresh sessions so worktree sessions appear in the list
if (state.getSessions().length > 0) {
this.panel?.sessions.refreshSessions()
}
// Recover any pending permission/question prompts that were missed during
// panel recreation or SSE reconnection. Must run after all worktree sessions
// are registered with their directory overrides so the recovery queries the
// correct CLI backend Instances.
this.panel?.sessions.recoverPendingPrompts()
}
// ---------------------------------------------------------------------------
@@ -232,8 +237,12 @@ export class AgentManagerProvider implements Disposable {
if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m.worktreeId)
if (m.type === "agentManager.promoteSession") return this.onPromoteSession(m.sessionId)
if (m.type === "agentManager.openLocally") {
if (!this.panel) return null
this.panel.sessions.clearSessionDirectory(m.sessionId)
this.panel?.sessions.clearSessionDirectory(m.sessionId)
const st = this.getStateManager()
if (st?.getSession(m.sessionId)) {
st.moveSession(m.sessionId, null)
this.pushState()
}
return null
}
if (m.type === "continueInWorktree") {
@@ -245,6 +254,15 @@ export class AgentManagerProvider implements Disposable {
if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId)
if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId)
if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId)
if (m.type === "agentManager.persistSession" || m.type === "agentManager.forgetSession") {
const persist = m.type === "agentManager.persistSession"
void this.stateReady?.then(() => {
const st = this.getStateManager()
if (st)
persist ? !st.getSession(m.sessionId) && st.addSession(m.sessionId, null) : st.removeSession(m.sessionId)
})
return null
}
if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) {
this.activeSessionId = m.draftID
}
@@ -400,6 +418,10 @@ export class AgentManagerProvider implements Disposable {
this.startDiffPolling(m.sessionId)
return null
}
if (m.type === "agentManager.openSessions") {
this.connectionService.registerOpen("agent-manager", m.sessionIDs)
return null
}
if (m.type === "agentManager.stopDiffWatch") {
this.stopDiffPolling()
return null
@@ -427,6 +449,7 @@ export class AgentManagerProvider implements Disposable {
// uses the correct session even before the session provider's async session.get completes.
if (m.type === "loadMessages") {
this.activeSessionId = m.sessionID
this.connectionService.registerFocused("agent-manager", m.sessionID)
this.terminalManager.syncOnSessionSwitch(m.sessionID)
this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined)
}
@@ -434,6 +457,7 @@ export class AgentManagerProvider implements Disposable {
// After clearSession, clear active tracking and re-register worktree sessions
if (m.type === "clearSession") {
this.activeSessionId = undefined
this.connectionService.unregisterFocused("agent-manager")
void Promise.resolve().then(() => {
if (!this.panel || !this.state) return
for (const id of this.state.worktreeSessionIds()) {
@@ -911,9 +935,9 @@ export class AgentManagerProvider implements Disposable {
continue
}
await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch)
await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch, wt.worktree.id)
const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch)
const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch, wt.worktree.id)
if (!session) {
const state = this.getStateManager()
const manager = this.getWorktreeManager()
@@ -926,7 +950,7 @@ export class AgentManagerProvider implements Disposable {
const state = this.getStateManager()!
state.addSession(session.id, wt.worktree.id)
this.registerWorktreeSession(session.id, wt.result.path)
this.notifyWorktreeReady(session.id, wt.result)
this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id)
// Set the per-version model immediately so the UI selector reflects
// the correct model as soon as the worktree appears, before Phase 2.
@@ -1404,6 +1428,7 @@ export class AgentManagerProvider implements Disposable {
status: "error",
message: `Setup script failed: ${msg}`,
branch,
worktreeId,
})
}
}
@@ -1432,6 +1457,10 @@ export class AgentManagerProvider implements Disposable {
if (!this.panel) return
this.panel.sessions.setSessionDirectory(sessionId, directory)
this.panel.sessions.trackSession(sessionId)
// Recover any permission/question prompts that arrived before the session
// was tracked. The CLI backend may have emitted permission.asked between
// session.create() returning and this registration completing.
this.panel.sessions.recoverPendingPrompts()
}
private onWorktreePresence(result: WorktreePresenceResult): void {
@@ -1987,6 +2016,8 @@ export class AgentManagerProvider implements Disposable {
}
public dispose(): void {
this.connectionService.unregisterFocused("agent-manager")
this.connectionService.registerOpen("agent-manager", [])
this.stopDiffPolling()
this.statsPoller.stop()
this.gitOps.dispose()
@@ -4,11 +4,14 @@ import * as fs from "fs/promises"
import { spawn } from "../util/process"
import simpleGit from "simple-git"
import { parseWorktreeList, normalizePath } from "./git-import"
import type { Semaphore } from "./semaphore"
interface GitOpsOptions {
log: (...args: unknown[]) => void
/** Override git command execution for testing. */
runGit?: (args: string[], cwd: string) => Promise<string>
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
}
export interface ApplyConflict {
@@ -63,6 +66,7 @@ export class GitOps {
private readonly log: (...args: unknown[]) => void
private readonly runGit: (args: string[], cwd: string) => Promise<string>
private readonly controller = new AbortController()
private readonly semaphore: Semaphore | undefined
get disposed(): boolean {
return this.controller.signal.aborted
@@ -70,6 +74,7 @@ export class GitOps {
constructor(options: GitOpsOptions) {
this.log = options.log
this.semaphore = options.semaphore
this.runGit =
options.runGit ??
((args, cwd) =>
@@ -87,20 +92,22 @@ export class GitOps {
private raw(args: string[], cwd: string): Promise<string> {
const signal = this.controller.signal
if (signal.aborted) return Promise.reject(new Error("GitOps disposed"))
return new Promise<string>((resolve, reject) => {
const onAbort = () => reject(new Error("GitOps disposed"))
signal.addEventListener("abort", onAbort, { once: true })
this.runGit(args, cwd).then(
(value) => {
signal.removeEventListener("abort", onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener("abort", onAbort)
reject(err)
},
)
})
const invoke = () =>
new Promise<string>((resolve, reject) => {
const onAbort = () => reject(new Error("GitOps disposed"))
signal.addEventListener("abort", onAbort, { once: true })
this.runGit(args, cwd).then(
(value) => {
signal.removeEventListener("abort", onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener("abort", onAbort)
reject(err)
},
)
})
return this.semaphore ? this.semaphore.run(invoke) : invoke()
}
/** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */
@@ -413,37 +420,39 @@ export class GitOps {
if (this.controller.signal.aborted) {
return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" })
}
return new Promise((resolve) => {
const child = spawn("git", args, {
cwd,
env: options?.env,
signal: this.controller.signal,
stdio: ["pipe", "pipe", "pipe"],
})
const invoke = () =>
new Promise<ExecResult>((resolve) => {
const child = spawn("git", args, {
cwd,
env: options?.env,
signal: this.controller.signal,
stdio: ["pipe", "pipe", "pipe"],
})
if (options?.stdin !== undefined) {
if (!child.stdin) {
resolve({ code: 1, stdout: "", stderr: "stdin not available for git process" })
return
if (options?.stdin !== undefined) {
if (!child.stdin) {
resolve({ code: 1, stdout: "", stderr: "stdin not available for git process" })
return
}
child.stdin.end(options.stdin)
}
child.stdin.end(options.stdin)
}
const out: Buffer[] = []
const err: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
const out: Buffer[] = []
const err: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
child.on("error", (error) => {
resolve({ code: 1, stdout: "", stderr: error.message })
})
child.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(out).toString("utf8"),
stderr: Buffer.concat(err).toString("utf8"),
child.on("error", (error) => {
resolve({ code: 1, stdout: "", stderr: error.message })
})
child.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(out).toString("utf8"),
stderr: Buffer.concat(err).toString("utf8"),
})
})
})
})
return this.semaphore ? this.semaphore.run(invoke) : invoke()
}
}
@@ -3,6 +3,7 @@ import * as path from "path"
import type { KiloClient, FileDiff } from "@kilocode/sdk/v2/client"
import { remoteRef, type Worktree } from "./WorktreeStateManager"
import type { GitOps } from "./GitOps"
import type { Semaphore } from "./semaphore"
import { normalizePath } from "./git-import"
export interface WorktreeStats {
@@ -45,6 +46,8 @@ interface GitStatsPollerOptions {
onWorktreePresence?: (result: WorktreePresenceResult) => void
log: (...args: unknown[]) => void
intervalMs?: number
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
}
export class GitStatsPoller {
@@ -154,15 +157,20 @@ export class GitStatsPoller {
return
}
// Gate the HTTP diffSummary call through the semaphore but NOT the
// aheadBehind call — that goes through GitOps.raw() which already
// acquires the same semaphore. Wrapping both would deadlock.
const gate = this.options.semaphore
const diff = (dir: string, base: string) => {
const invoke = () => client.worktree.diffSummary({ directory: dir, base }, { throwOnError: true })
return gate ? gate.run(invoke) : invoke()
}
const stats = (
await Promise.all(
active.map(async (wt) => {
try {
const base = remoteRef(wt)
const [{ data: diffs }, ab] = await Promise.all([
client.worktree.diffSummary({ directory: wt.path, base }, { throwOnError: true }),
this.git.aheadBehind(wt.path, base),
])
const [{ data: diffs }, ab] = await Promise.all([diff(wt.path, base), this.git.aheadBehind(wt.path, base)])
const files = diffs.length
const additions = diffs.reduce((sum: number, diff: FileDiff) => sum + diff.additions, 0)
const deletions = diffs.reduce((sum: number, diff: FileDiff) => sum + diff.deletions, 0)
@@ -260,8 +268,10 @@ export class GitStatsPoller {
try {
if (base && client) {
this.options.log(`Local stats: using HTTP client with base=${base}`)
const gate = this.options.semaphore
const invoke = () => client.worktree.diffSummary({ directory: root, base }, { throwOnError: true })
const [{ data: diffs }, ab] = await Promise.all([
client.worktree.diffSummary({ directory: root, base }, { throwOnError: true }),
gate ? gate.run(invoke) : invoke(),
this.git.aheadBehind(root, base),
])
files = diffs.length
@@ -1,7 +1,9 @@
import type { ExecFileOptionsWithStringEncoding } from "child_process"
import type { Worktree } from "./WorktreeStateManager"
import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types"
import { execWithShellEnv } from "./shell-env"
import { classifyPRError } from "./git-import"
import type { Semaphore } from "./semaphore"
interface PRStatusPollerOptions {
getWorktrees: () => Worktree[]
@@ -9,6 +11,8 @@ interface PRStatusPollerOptions {
onStatus: (worktreeId: string, pr: PRStatus | null, error?: "gh_missing" | "gh_auth" | "fetch_failed") => void
log: (...args: unknown[]) => void
intervalMs?: number
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
}
const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime
@@ -33,9 +37,21 @@ export class PRStatusPoller {
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
private lastFullSync = 0 // timestamp of last full (all-worktree) sync
private readonly intervalMs: number
private readonly semaphore: Semaphore | undefined
constructor(private readonly options: PRStatusPollerOptions) {
this.intervalMs = options.intervalMs ?? 15_000
this.semaphore = options.semaphore
}
/** Run a command through the shared concurrency gate (when configured). */
private shell(
cmd: string,
args: string[],
options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">,
): Promise<{ stdout: string; stderr: string }> {
const invoke = () => execWithShellEnv(cmd, args, options)
return this.semaphore ? this.semaphore.run(invoke) : invoke()
}
setEnabled(enabled: boolean): void {
@@ -140,7 +156,7 @@ export class PRStatusPoller {
return this.ghAvailable
}
try {
await execWithShellEnv("gh", ["--version"], { timeout: 5_000 })
await this.shell("gh", ["--version"], { timeout: 5_000 })
this.ghAvailable = true
} catch {
this.ghAvailable = false
@@ -279,7 +295,7 @@ export class PRStatusPoller {
if (branch) args.push(branch)
args.push("--json", PRStatusPoller.PR_JSON_FIELDS)
const { stdout } = await execWithShellEnv("gh", args, { cwd, timeout: 15_000 })
const { stdout } = await this.shell("gh", args, { cwd, timeout: 15_000 })
return parsePRResult(stdout)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
@@ -291,11 +307,11 @@ export class PRStatusPoller {
/** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */
private async ghPRListBySHA(cwd: string): Promise<PRResult | null> {
try {
const { stdout: sha } = await execWithShellEnv("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 })
const { stdout: sha } = await this.shell("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 })
const head = sha.trim()
if (!head) return null
const { stdout } = await execWithShellEnv(
const { stdout } = await this.shell(
"gh",
[
"pr",
@@ -337,7 +353,7 @@ export class PRStatusPoller {
items: PRCheck[]
}> {
try {
const { stdout } = await execWithShellEnv(
const { stdout } = await this.shell(
"gh",
["pr", "checks", String(prNumber), "--json", "name,state,link,startedAt,completedAt"],
{ cwd, timeout: 15_000 },
@@ -375,7 +391,7 @@ export class PRStatusPoller {
if (this.cachedRepo && this.cachedRepo.cwd === cwd) {
return this.cachedRepo
}
const { stdout } = await execWithShellEnv("gh", ["repo", "view", "--json", "owner,name"], {
const { stdout } = await this.shell("gh", ["repo", "view", "--json", "owner,name"], {
cwd,
timeout: 10_000,
})
@@ -415,7 +431,7 @@ export class PRStatusPoller {
}
}`
const { stdout } = await execWithShellEnv(
const { stdout } = await this.shell(
"gh",
[
"api",
@@ -858,6 +858,15 @@ export class WorktreeManager {
this.log(`defaultBranch: branchLocal failed: ${e}`)
}
// Check if this is an empty repo with no commits (unborn branch).
// rev-parse --verify HEAD exits non-zero only when HEAD has no target
// commit, which is the definitive test for an unborn branch.
try {
await this.git.raw(["rev-parse", "--verify", "HEAD"])
} catch {
throw new Error("This repository has no commits yet. Create an initial commit before using worktrees.")
}
throw new Error("Could not determine default branch")
}
@@ -247,12 +247,12 @@ export class WorktreeStateManager {
return session
}
/** Move an existing session to a worktree (promotion). */
moveSession(sessionId: string, worktreeId: string): void {
/** Move an existing session to a worktree (or back to local when null). */
moveSession(sessionId: string, worktreeId: string | null): void {
const session = this.sessions.get(sessionId)
if (!session) return
session.worktreeId = worktreeId
this.log(`Moved session ${sessionId} to worktree ${worktreeId}`)
this.log(`Moved session ${sessionId} to ${worktreeId ?? "local"}`)
void this.save()
}
@@ -304,6 +304,11 @@ export class WorktreeStateManager {
if (!wt.sectionId) top.add(wt.id)
}
this.worktreeOrder = order.filter((id) => top.has(id))
// Append any sections/ungrouped worktrees missing from the incoming order
const present = new Set(this.worktreeOrder)
for (const id of top) {
if (!present.has(id)) this.worktreeOrder.push(id)
}
void this.save()
}
@@ -380,6 +385,11 @@ export class WorktreeStateManager {
}
moveSection(id: string, dir: -1 | 1): void {
// Ensure the section is in worktreeOrder (it may be missing if drag-and-drop
// overwrote the order before this section was tracked)
if (this.sections.has(id) && !this.worktreeOrder.includes(id)) {
this.worktreeOrder.push(id)
}
const top = this.worktreeOrder.filter((item) => {
if (this.sections.has(item)) return true
const wt = this.worktrees.get(item)
@@ -29,7 +29,7 @@ interface WorktreeEntry {
type PRErrorKind = "not_found" | "gh_missing" | "gh_auth" | "unknown"
export type WorktreeSetupErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing"
export type WorktreeSetupErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing" | "no_commits"
export function parsePRUrl(url: string): PRUrlParts | null {
let normalized = url.trim()
@@ -158,5 +158,6 @@ export function classifyWorktreeError(msg: string): WorktreeSetupErrorCode | und
if (msg.includes("ENOENT") || msg.includes("not found in PATH")) return "git_not_found"
if (msg.includes("not a git repository")) return "not_git_repo"
if (msg.includes("Git LFS") && msg.includes("not found")) return "lfs_missing"
if (msg.includes("no commits yet")) return "no_commits"
return undefined
}
@@ -38,6 +38,8 @@ export interface SessionProvider {
trackSession(id: string): void
refreshSessions(): void
registerSession(session: Session): void
/** Recover any pending permission/question prompts for tracked sessions. */
recoverPendingPrompts(): void
dispose(): void
}
@@ -7,6 +7,7 @@
import type { Worktree } from "./WorktreeStateManager"
import type { AgentManagerOutMessage, PRStatus } from "./types"
import type { Disposable } from "./host"
import type { Semaphore } from "./semaphore"
import { PRStatusPoller } from "./PRStatusPoller"
interface PRBridgeHost {
@@ -17,6 +18,7 @@ interface PRBridgeHost {
hasPersistedPR(id: string): boolean
openExternal(url: string): void
log(...args: unknown[]): void
semaphore?: Semaphore
}
/** Minimal panel surface needed by the bridge (subset of PanelContext). */
@@ -43,6 +45,7 @@ export class PRStatusBridge {
hasPersistedPR: (id: string) => boolean
openExternal: (url: string) => void
log: (...args: unknown[]) => void
semaphore?: Semaphore
}): PRStatusBridge {
return new PRStatusBridge(opts)
}
@@ -85,6 +88,7 @@ function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) {
return {
getWorktrees: () => host.getWorktrees(),
getWorkspaceRoot: () => host.getWorkspaceRoot(),
semaphore: host.semaphore,
onStatus: (id: string, pr: PRStatus | null, err?: "gh_missing" | "gh_auth" | "fetch_failed") => {
if (err) {
// Don't forward errors to the webview when we have prior PR data
@@ -0,0 +1,41 @@
/**
* Bounded-concurrency gate for git/gh child processes.
*
* Shared across GitOps and PRStatusPoller so that all polling loops
* (GitStatsPoller, PRStatusPoller, diff watcher) compete for the same
* slots. Prevents process storms when many worktrees are active.
*/
export class Semaphore {
private running = 0
private readonly pending: (() => void)[] = []
constructor(private readonly limit: number) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire()
try {
return await fn()
} finally {
this.release()
}
}
private acquire(): Promise<void> {
if (this.running < this.limit) {
this.running++
return Promise.resolve()
}
return new Promise<void>((resolve) => {
this.pending.push(() => {
this.running++
resolve()
})
})
}
private release(): void {
this.running--
const next = this.pending.shift()
if (next) next()
}
}
@@ -28,7 +28,16 @@ export async function executeVscodeTask(config: SetupTaskConfig): Promise<number
showReuseMessage: false,
}
const execution = await vscode.tasks.executeTask(task)
let execution: vscode.TaskExecution
try {
execution = await vscode.tasks.executeTask(task)
} catch {
// Task type may not be registered in certain VS Code environments
// (e.g. remote, codespaces, or if package.json contribution is not loaded yet).
// Return undefined so SetupScriptRunner treats it as a non-fatal skip
// rather than VS Code surfacing its own error notification.
return undefined
}
return new Promise((resolve, reject) => {
let done = false
@@ -314,6 +314,18 @@ interface CloseSessionIn {
sessionId: string
}
/** Persist a non-worktree session to agent-manager.json (worktreeId = null). */
interface PersistSessionIn {
type: "agentManager.persistSession"
sessionId: string
}
/** Remove a non-worktree session from agent-manager.json. */
interface ForgetSessionIn {
type: "agentManager.forgetSession"
sessionId: string
}
interface ConfigureSetupScriptIn {
type: "agentManager.configureSetupScript"
}
@@ -465,6 +477,11 @@ interface OpenPRIn {
worktreeId: string
}
interface OpenSessionsIn {
type: "agentManager.openSessions"
sessionIDs: string[]
}
interface OpenFileIn {
type: "agentManager.openFile"
sessionId: string
@@ -589,6 +606,8 @@ export type AgentManagerInMessage =
| OpenLocallyIn
| AddSessionToWorktreeIn
| CloseSessionIn
| PersistSessionIn
| ForgetSessionIn
| ForkSessionIn
| ConfigureSetupScriptIn
| ShowTerminalIn
@@ -619,6 +638,7 @@ export type AgentManagerInMessage =
| RevertWorktreeFileIn
| RefreshPRIn
| OpenPRIn
| OpenSessionsIn
| OpenFileIn
| GenericOpenFileIn
| PreviewImageIn
@@ -95,6 +95,7 @@ export class VscodeHost implements Host {
trackSession: (id) => provider.trackSession(id),
refreshSessions: () => provider.refreshSessions(),
registerSession: (s) => provider.registerSession(s),
recoverPendingPrompts: () => provider.recoverPendingPrompts(),
dispose: () => provider.dispose(),
}
+33 -2
View File
@@ -16,6 +16,7 @@ import { TelemetryProxy } from "./services/telemetry"
import { registerCommitMessageService } from "./services/commit-message"
import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions"
import { registerToggleAutoApprove } from "./commands/toggle-auto-approve"
import { RemoteStatusService } from "./services/RemoteStatusService"
// Activated via "onStartupFinished" (package.json) so that commands, code actions, keybindings,
// autocomplete, commit-message generation, and URI deep links all work immediately — without
@@ -33,8 +34,13 @@ export function activate(context: vscode.ExtensionContext) {
const browserAutomationService = new BrowserAutomationService(connectionService)
browserAutomationService.syncWithSettings()
// Create remote status service (one status bar item for all webviews)
const remoteService = new RemoteStatusService()
context.subscriptions.push(remoteService)
connectionService.setRemoteService(remoteService)
// Re-register browser automation MCP server on CLI backend reconnect, configure telemetry,
// and reload autocomplete so it picks up the now-available backend connection.
// set remote service client, and reload autocomplete so it picks up the now-available backend connection.
const unsubscribeStateChange = connectionService.onStateChange((state) => {
if (state === "connected") {
browserAutomationService.reregisterIfEnabled()
@@ -42,7 +48,17 @@ export function activate(context: vscode.ExtensionContext) {
if (config) {
telemetry.configure(config.baseUrl, config.password)
}
try {
remoteService.setClient(connectionService.getClient())
console.log("[Kilo New] CLI connected, calling remoteService.refresh()")
remoteService.refresh().catch((err) => console.warn("[Kilo New] initial remote refresh failed:", err))
} catch {
remoteService.setClient(null)
}
AutocompleteServiceManager.getInstance()?.load()
} else {
remoteService.clearState()
remoteService.setClient(null)
}
})
@@ -60,6 +76,7 @@ export function activate(context: vscode.ExtensionContext) {
// Create the provider with shared service
const provider = new KiloProvider(context.extensionUri, connectionService, context)
provider.setRemoteService(remoteService)
// Register the webview view provider for the sidebar.
// retainContextWhenHidden keeps the webview alive when switching to other sidebar panels.
@@ -103,6 +120,7 @@ export function activate(context: vscode.ExtensionContext) {
vscode.window.registerWebviewPanelSerializer("kilo-code.new.TabPanel", {
deserializeWebviewPanel(panel: vscode.WebviewPanel) {
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context)
tabProvider.setRemoteService(remoteService)
tabProvider.setContinueInWorktreeHandler((sessionId, progress) =>
agentManagerProvider.continueFromSidebar(sessionId, progress),
)
@@ -138,6 +156,7 @@ export function activate(context: vscode.ExtensionContext) {
// Create settings/profile editor provider (opens in editor area, not sidebar)
const settingsEditorProvider = new SettingsEditorProvider(context.extensionUri, connectionService, context)
settingsEditorProvider.setRemoteService(remoteService)
context.subscriptions.push(settingsEditorProvider)
// Create sub-agent viewer provider (read-only editor panel for sub-agent sessions)
@@ -228,8 +247,18 @@ export function activate(context: vscode.ExtensionContext) {
await provider.waitForReady()
provider.postMessage({ type: "triggerTask", text: `Generate a terminal command: ${input}` })
}),
vscode.commands.registerCommand("kilo-code.new.toggleRemote", () => {
remoteService.toggle().catch((err) => console.error("[Kilo New] toggleRemote command failed:", err))
}),
vscode.commands.registerCommand("kilo-code.new.openInTab", () => {
return openKiloInNewTab(context, connectionService, agentManagerProvider, tabPanels, diffVirtualProvider)
return openKiloInNewTab(
context,
connectionService,
agentManagerProvider,
tabPanels,
diffVirtualProvider,
remoteService,
)
}),
vscode.commands.registerCommand("kilo-code.new.showChanges", () => {
diffViewerProvider.openPanel()
@@ -362,6 +391,7 @@ async function openKiloInNewTab(
agentManagerProvider: AgentManagerProvider,
tabPanels: Map<vscode.WebviewPanel, KiloProvider>,
diffVirtualProvider: DiffVirtualProvider,
remoteService: RemoteStatusService,
) {
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0)
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
@@ -384,6 +414,7 @@ async function openKiloInNewTab(
}
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context)
tabProvider.setRemoteService(remoteService)
tabProvider.setContinueInWorktreeHandler((sessionId, progress) =>
agentManagerProvider.continueFromSidebar(sessionId, progress),
)
@@ -0,0 +1,129 @@
import * as vscode from "vscode"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { t } from "./cli-backend/i18n"
export type RemoteState = { enabled: boolean; connected: boolean }
type Listener = (state: RemoteState) => void
/**
* Singleton service that owns all remote-control state and the VS Code status bar item.
* Replaces the per-webview polling in RemoteIndicator.tsx and ExperimentalTab.tsx
* with a push-based model: one status bar item, zero recurring cost for non-remote users.
*/
export class RemoteStatusService implements vscode.Disposable {
private state: RemoteState = { enabled: false, connected: false }
private bar: vscode.StatusBarItem
private listeners = new Set<Listener>()
private client: KiloClient | null = null
constructor() {
this.bar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99)
this.bar.command = "kilo-code.new.toggleRemote"
this.sync()
}
setClient(c: KiloClient | null): void {
this.client = c
}
/** Get current state synchronously. */
getState(): RemoteState {
return this.state
}
updateFromEvent(state: RemoteState): void {
this.update(state)
}
/** Subscribe to state changes. Returns an unsubscribe function. */
onChange(cb: Listener): () => void {
this.listeners.add(cb)
return () => this.listeners.delete(cb)
}
clearState(): void {
this.update({ enabled: false, connected: false })
}
/** One-shot status fetch — broadcasts via onChange if state changed. */
async refresh(): Promise<void> {
if (!this.client) return
const res = await this.client.remote.status().catch((err: unknown) => {
console.warn("[Kilo] remote status refresh failed:", err)
return undefined
})
if (!res?.data) return
this.update({ enabled: res.data.enabled, connected: res.data.connected })
}
/** Toggle remote on/off based on current state. */
async toggle(): Promise<void> {
if (!this.client) return
const { data } = await this.client.remote.status(undefined, { throwOnError: true })
await this.setEnabled(!data.enabled)
}
/** Enable or disable remote. State updates are pushed via events. */
async setEnabled(enabled: boolean): Promise<void> {
if (!this.client) return
if (enabled) {
await this.client.remote.enable(undefined, { throwOnError: true })
} else {
await this.client.remote.disable(undefined, { throwOnError: true })
}
this.update({ enabled, connected: false })
}
/**
* Handle a remote-related webview message.
* Returns a response message to post back to the webview, or null.
*/
async handleMessage(type: string, enabled?: boolean): Promise<RemoteState | null> {
switch (type) {
case "toggleRemote":
await this.toggle()
return null
case "setRemoteEnabled":
if (enabled === undefined) return null
await this.setEnabled(enabled)
return null
case "requestRemoteStatus":
void this.refresh()
return this.state
}
return null
}
dispose(): void {
this.listeners.clear()
this.bar.dispose()
}
// -- internal ---------------------------------------------------------------
private update(next: RemoteState): void {
if (this.state.enabled === next.enabled && this.state.connected === next.connected) return
this.state = next
this.sync()
for (const cb of this.listeners) cb(next)
}
/** Sync status bar appearance to current state. */
private sync(): void {
if (!this.state.enabled) {
this.bar.hide()
return
}
if (this.state.connected) {
this.bar.text = "$(radio-tower) Kilo Remote"
this.bar.tooltip = t("remote.connected")
this.bar.color = new vscode.ThemeColor("testing.iconPassed")
} else {
this.bar.text = "$(radio-tower) Kilo Remote \u2026"
this.bar.tooltip = t("remote.connecting")
this.bar.color = new vscode.ThemeColor("editorWarning.foreground")
}
this.bar.show()
}
}
@@ -34,6 +34,7 @@ export class KiloConnectionService {
private state: ConnectionState = "disconnected"
private connectPromise: Promise<void> | null = null
private healthPollTimer: ReturnType<typeof setInterval> | null = null
private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null
private readonly eventListeners: Set<SSEEventListener> = new Set()
private readonly stateListeners: Set<StateListener> = new Set()
@@ -51,6 +52,13 @@ export class KiloConnectionService {
*/
private readonly messageSessionIdsByMessageId: Map<string, string> = new Map()
/** Provider key → single focused session ID. */
private readonly focused: Map<string, string> = new Map()
/** Provider key → all open (background) session IDs. */
private readonly opened: Map<string, string[]> = new Map()
private debounceTimer: ReturnType<typeof setTimeout> | null = null
private unsubRemote: (() => void) | null = null
constructor(context: vscode.ExtensionContext) {
this.serverManager = new ServerManager(context)
}
@@ -106,6 +114,27 @@ export class KiloConnectionService {
return this.config
}
/**
* Set the remote status service. When remote is disabled, flushViewed()
* is a no-op. When remote becomes enabled (startup refresh, user toggle,
* or SSE event), the accumulated focused/opened state is automatically
* flushed so the server is never left unaware of already-open sessions.
*/
setRemoteService(service: import("../RemoteStatusService").RemoteStatusService | null): void {
this.unsubRemote?.()
this.unsubRemote = null
this.remoteService = service
if (service) {
this.unsubRemote = service.onChange((state) => {
if (state.enabled) this.flushViewed()
})
}
}
private isRemoteEnabled(): boolean {
return this.remoteService?.getState().enabled ?? false
}
/**
* Current connection state.
*/
@@ -345,6 +374,55 @@ export class KiloConnectionService {
}
}
/**
* Register the session a provider is actively viewing (focused).
* After any change the aggregated set is sent to the server (debounced).
*/
registerFocused(key: string, sessionID: string): void {
if (this.focused.get(key) === sessionID) return
this.focused.set(key, sessionID)
this.flushViewed()
}
/**
* Unregister a provider's focused session (e.g. on dispose, hidden, or clearSession).
*/
unregisterFocused(key: string): void {
if (!this.focused.has(key)) return
this.focused.delete(key)
this.flushViewed()
}
/**
* Register the open (background tab) session IDs for a provider.
* Sessions that appear in both focused and open are reported as focused only.
*/
registerOpen(key: string, ids: string[]): void {
const prev = this.opened.get(key)
if (prev && prev.length === ids.length && prev.every((v, i) => v === ids[i])) return
this.opened.set(key, ids)
this.flushViewed()
}
/** Debounced: send the aggregated focused + open session IDs to the server. */
flushViewed(): void {
if (!this.isRemoteEnabled()) return
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))
}, 150)
}
/**
* Clean up everything: kill server, close SSE, clear listeners.
*/
@@ -361,6 +439,14 @@ export class KiloConnectionService {
this.clearPendingPromptsListeners.clear()
this.directoryProviders.clear()
this.messageSessionIdsByMessageId.clear()
this.focused.clear()
this.opened.clear()
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
this.unsubRemote?.()
this.unsubRemote = null
this.client = null
this.sseClient = null
this.config = null
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "انتهت عملية CLI بالرمز {{code}} قبل بدء الخادم",
"server.startupTimeout": "انتهت مهلة بدء تشغيل الخادم بعد {{seconds}} ثانية",
"remote.connected": "Kilo Remote: متصل",
"remote.connecting": "Kilo Remote: جارٍ الاتصال\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "O processo da CLI foi encerrado com o código {{code}} antes que o servidor fosse iniciado",
"server.startupTimeout": "Tempo limite de inicialização do servidor esgotado após {{seconds}} segundos",
"remote.connected": "Kilo Remote: Conectado",
"remote.connecting": "Kilo Remote: Conectando\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI proces je izašao sa kodom {{code}} prije nego što se server pokrenuo",
"server.startupTimeout": "Vrijeme pokretanja servera je isteklo nakon {{seconds}} sekundi",
"remote.connected": "Kilo Remote: Povezano",
"remote.connecting": "Kilo Remote: Povezivanje\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI-processen afsluttede med kode {{code}} før serveren startede",
"server.startupTimeout": "Serverens opstartstid udløb efter {{seconds}} sekunder",
"remote.connected": "Kilo Remote: Forbundet",
"remote.connecting": "Kilo Remote: Forbinder\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Der CLI-Prozess wurde mit dem Code {{code}} beendet, bevor der Server gestartet wurde",
"server.startupTimeout": "Zeitüberschreitung beim Serverstart nach {{seconds}} Sekunden",
"remote.connected": "Kilo Remote: Verbunden",
"remote.connecting": "Kilo Remote: Verbindung wird hergestellt\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI process exited with code {{code}} before server started",
"server.startupTimeout": "Server startup timeout after {{seconds}} seconds",
"remote.connected": "Kilo Remote: Connected",
"remote.connecting": "Kilo Remote: Connecting\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "El proceso de la CLI finalizó con el código {{code}} antes de que se iniciara el servidor",
"server.startupTimeout": "Tiempo de espera de inicio del servidor agotado después de {{seconds}} segundos",
"remote.connected": "Kilo Remote: Conectado",
"remote.connecting": "Kilo Remote: Conectando\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Le processus CLI s'est terminé avec le code {{code}} avant le démarrage du serveur",
"server.startupTimeout": "Délai de démarrage du serveur dépassé après {{seconds}} secondes",
"remote.connected": "Kilo Remote\u00a0: Connecté",
"remote.connecting": "Kilo Remote\u00a0: Connexion\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "サーバーが起動する前に、CLI プロセスがコード {{code}} で終了しました",
"server.startupTimeout": "サーバーの起動が {{seconds}} 秒後にタイムアウトしました",
"remote.connected": "Kilo Remote: 接続済み",
"remote.connecting": "Kilo Remote: 接続中\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "서버가 시작되기 전에 CLI 프로세스가 코드 {{code}}로 종료되었습니다",
"server.startupTimeout": "{{seconds}}초 후 서버 시작 시간이 초과되었습니다",
"remote.connected": "Kilo Remote: 연결됨",
"remote.connecting": "Kilo Remote: 연결 중\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI-proces is afgesloten met code {{code}} voordat de server is gestart",
"server.startupTimeout": "Time-out bij opstarten van server na {{seconds}} seconden",
"remote.connected": "Kilo Remote: Verbonden",
"remote.connecting": "Kilo Remote: Verbinden\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI-prosessen avsluttet med kode {{code}} før serveren startet",
"server.startupTimeout": "Tidsavbrudd for serveroppstart etter {{seconds}} sekunder",
"remote.connected": "Kilo Remote: Tilkoblet",
"remote.connecting": "Kilo Remote: Kobler til\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Proces CLI zakończył się z kodem {{code}} przed uruchomieniem serwera",
"server.startupTimeout": "Przekroczono limit czasu uruchamiania serwera po {{seconds}} sekundach",
"remote.connected": "Kilo Remote: Połączono",
"remote.connecting": "Kilo Remote: Łączenie\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Процесс CLI завершился с кодом {{code}} до запуска сервера",
"server.startupTimeout": "Время ожидания запуска сервера истекло через {{seconds}} секунд",
"remote.connected": "Kilo Remote: Подключено",
"remote.connecting": "Kilo Remote: Подключение\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "กระบวนการ CLI ออกด้วยรหัส {{code}} ก่อนที่เซิร์ฟเวอร์จะเริ่มทำงาน",
"server.startupTimeout": "หมดเวลาการเริ่มต้นเซิร์ฟเวอร์หลังจาก {{seconds}} วินาที",
"remote.connected": "Kilo Remote: เชื่อมต่อแล้ว",
"remote.connecting": "Kilo Remote: กำลังเชื่อมต่อ\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI işlemi sunucu başlamadan önce {{code}} koduyla çıktı",
"server.startupTimeout": "{{seconds}} saniye sonra sunucu başlatma zaman aşımı",
"remote.connected": "Kilo Remote: Bağlandı",
"remote.connecting": "Kilo Remote: Bağlanıyor\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Процес CLI завершився з кодом {{code}} до запуску сервера",
"server.startupTimeout": "Час очікування запуску сервера вичерпано після {{seconds}} секунд",
"remote.connected": "Kilo Remote: Підключено",
"remote.connecting": "Kilo Remote: Підключення\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "在服务器启动之前,CLI 进程已退出,代码为 {{code}}",
"server.startupTimeout": "服务器启动在 {{seconds}} 秒后超时",
"remote.connected": "Kilo Remote: 已连接",
"remote.connecting": "Kilo Remote: 正在连接\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "在伺服器啟動之前,CLI 處理程序已退出,代碼為 {{code}}",
"server.startupTimeout": "伺服器啟動在 {{seconds}} 秒後逾時",
"remote.connected": "Kilo Remote: 已連線",
"remote.connecting": "Kilo Remote: 正在連線\u2026",
} as const
@@ -60,6 +60,10 @@ const mockVscode = {
stat: async () => ({ type: 1, ctime: 0, mtime: 0, size: 0 }),
},
},
StatusBarAlignment: { Left: 1, Right: 2 },
ThemeColor: class {
constructor(public id: string) {}
},
window: {
activeTextEditor: undefined,
visibleTextEditors: [],
@@ -67,6 +71,15 @@ const mockVscode = {
showTextDocument: async () => {},
showWarningMessage: async () => undefined,
createTerminal: () => ({ show: noop, sendText: noop, dispose: noop }),
createStatusBarItem: () => ({
text: "",
tooltip: "",
color: undefined as unknown,
command: undefined as string | undefined,
show: noop,
hide: noop,
dispose: noop,
}),
},
commands: {
registerCommand: () => ({ dispose: noop }),
@@ -179,6 +179,8 @@ describe("Agent Manager Provider — onMessage routing", () => {
"agentManager.addSessionToWorktree",
"agentManager.forkSession",
"agentManager.closeSession",
"agentManager.persistSession",
"agentManager.forgetSession",
"agentManager.configureSetupScript",
"agentManager.showTerminal",
"agentManager.showLocalTerminal",
@@ -534,8 +536,8 @@ const VSCODE_ALLOWED: Record<string, { note: string }> = {
*/
const MAX_LINES: Record<string, { maxLines: number; note: string }> = {
"AgentManagerProvider.ts": {
maxLines: 2000,
note: "primary extraction target: break into smaller orchestrators",
maxLines: 2050,
note: "permission recovery wiring is interleaved with panel/session lifecycle; extract more orchestrators next",
},
}
@@ -3,9 +3,10 @@ import * as fs from "fs/promises"
import * as os from "os"
import * as nodePath from "path"
import { GitOps } from "../../src/agent-manager/GitOps"
import { Semaphore } from "../../src/agent-manager/semaphore"
function ops(handler: (args: string[], cwd: string) => Promise<string>): GitOps {
return new GitOps({ log: () => undefined, runGit: handler })
function ops(handler: (args: string[], cwd: string) => Promise<string>, semaphore?: Semaphore): GitOps {
return new GitOps({ log: () => undefined, runGit: handler, semaphore })
}
function sleep(ms: number): Promise<void> {
@@ -556,4 +557,37 @@ describe("GitOps", () => {
expect(git.disposed).toBe(true)
})
})
describe("semaphore integration", () => {
it("limits concurrent raw() calls", async () => {
let running = 0
let peak = 0
const sem = new Semaphore(2)
const git = ops(async () => {
running++
peak = Math.max(peak, running)
await sleep(10)
running--
return "ok"
}, sem)
await Promise.all(Array.from({ length: 6 }, () => git.currentBranch("/repo")))
expect(peak).toBe(2)
})
it("works without a semaphore (no gating)", async () => {
let running = 0
let peak = 0
const git = ops(async () => {
running++
peak = Math.max(peak, running)
await sleep(10)
running--
return "ok"
})
await Promise.all(Array.from({ length: 4 }, () => git.currentBranch("/repo")))
expect(peak).toBe(4)
})
})
})
@@ -5,6 +5,7 @@ import * as path from "path"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller"
import { GitOps } from "../../src/agent-manager/GitOps"
import { Semaphore } from "../../src/agent-manager/semaphore"
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
function sleep(ms: number): Promise<void> {
@@ -430,4 +431,55 @@ describe("GitStatsPoller", () => {
const fetches = commands.filter((cmd) => cmd[0] === "fetch")
expect(fetches.length).toBe(0)
})
it("limits concurrent diffSummary calls when semaphore is provided", async () => {
let running = 0
let peak = 0
let ticks = 0
const sem = new Semaphore(2)
const client = {
worktree: {
diffSummary: async () => {
running++
peak = Math.max(peak, running)
await sleep(20)
running--
return { data: diff(1, 0) }
},
},
} as unknown as KiloClient
// Wire the SAME semaphore into GitOps to prove there's no deadlock —
// aheadBehind acquires the semaphore independently, not nested inside
// the diffSummary gate.
const wts = Array.from({ length: 5 }, (_, i) => worktree(String(i)))
const poller = new GitStatsPoller({
getWorktrees: () => wts,
getWorkspaceRoot: () => undefined,
getClient: () => client,
onStats: () => {
ticks++
},
onLocalStats: () => undefined,
log: () => undefined,
intervalMs: 5,
semaphore: sem,
git: new GitOps({
log: () => undefined,
semaphore: sem,
runGit: async (args) => {
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t0"
return ""
},
}),
})
poller.setEnabled(true)
await waitFor(() => ticks >= 1)
poller.stop()
// Only diffSummary calls are tracked — they should be bounded.
expect(peak).toBeLessThanOrEqual(2)
})
})
@@ -1,5 +1,11 @@
import { describe, it, expect } from "bun:test"
import { resolveNavigation, validateLocalSession, adjacentHint, LOCAL } from "../../webview-ui/agent-manager/navigate"
import {
resolveNavigation,
validateLocalSession,
adjacentHint,
restoreLocalSessions,
LOCAL,
} from "../../webview-ui/agent-manager/navigate"
const ids = ["a", "b", "c", "d"]
@@ -180,3 +186,99 @@ describe("adjacentHint", () => {
expect(adjacentHint("b", "a", ["a", "b"], "prev", "next")).toBe("next")
})
})
describe("restoreLocalSessions", () => {
const identity = (items: { id: string }[], _order: string[]) => items
const isPending = (id: string) => id.startsWith("pending-")
// Simulates applyTabOrder: reorders items to match the order array
const reorder = (items: { id: string }[], order: string[]) => {
const lookup = new Map(items.map((item) => [item.id, item]))
const result: { id: string }[] = []
for (const id of order) {
const item = lookup.get(id)
if (item) {
result.push(item)
lookup.delete(id)
}
}
for (const item of lookup.values()) result.push(item)
return result
}
it("restores local sessions when current list is empty", () => {
const sessions = [
{ id: "s1", worktreeId: null },
{ id: "s2", worktreeId: null },
]
const result = restoreLocalSessions(sessions, [], undefined, isPending, identity)
expect(result).toEqual(["s1", "s2"])
})
it("skips worktree-bound sessions", () => {
const sessions = [
{ id: "s1", worktreeId: "wt-1" },
{ id: "s2", worktreeId: null },
{ id: "s3", worktreeId: "wt-2" },
]
const result = restoreLocalSessions(sessions, [], undefined, isPending, identity)
expect(result).toEqual(["s2"])
})
it("applies tab order on restore", () => {
const sessions = [
{ id: "s1", worktreeId: null },
{ id: "s2", worktreeId: null },
{ id: "s3", worktreeId: null },
]
const result = restoreLocalSessions(sessions, [], ["s3", "s1", "s2"], isPending, reorder)
expect(result).toEqual(["s3", "s1", "s2"])
})
it("does not overwrite existing real sessions", () => {
const sessions = [
{ id: "s1", worktreeId: null },
{ id: "s2", worktreeId: null },
]
// Current already has real sessions — don't replace
const result = restoreLocalSessions(sessions, ["s1", "s2"], undefined, isPending, identity)
expect(result).toBeUndefined()
})
it("does restore when current only has pending tabs", () => {
const sessions = [
{ id: "s1", worktreeId: null },
{ id: "s2", worktreeId: null },
]
const result = restoreLocalSessions(sessions, ["pending-1"], undefined, isPending, identity)
expect(result).toEqual(["s1", "s2"])
})
it("returns undefined when no local sessions and no tab order", () => {
const sessions = [{ id: "s1", worktreeId: "wt-1" }]
const result = restoreLocalSessions(sessions, [], undefined, isPending, identity)
expect(result).toBeUndefined()
})
it("applies tab order to existing sessions", () => {
const sessions = [{ id: "s1", worktreeId: null }]
const result = restoreLocalSessions(sessions, ["s2", "s1"], ["s1", "s2"], isPending, reorder)
expect(result).toEqual(["s1", "s2"])
})
it("merges disk session missing from stale webview state", () => {
const sessions = [
{ id: "s1", worktreeId: null },
{ id: "s2", worktreeId: null },
{ id: "s3", worktreeId: null },
]
// webview state is stale: has s1, s2 but not s3 (debounce didn't fire)
const result = restoreLocalSessions(sessions, ["s1", "s2"], undefined, isPending, identity)
expect(result).toEqual(["s1", "s2", "s3"])
})
it("returns undefined when no disk sessions and no tab order", () => {
const result = restoreLocalSessions([], [], undefined, isPending, identity)
expect(result).toBeUndefined()
})
})
@@ -0,0 +1,301 @@
import { describe, it, expect, spyOn } from "bun:test"
import { RemoteStatusService, type RemoteState } from "../../src/services/RemoteStatusService"
type StatusResponse = { enabled: boolean; connected: boolean }
function client(opts: { status?: StatusResponse | (() => StatusResponse); fail?: boolean }) {
return {
remote: {
status: async (_body?: unknown, _opts?: unknown) => {
if (opts.fail) throw new Error("connection refused")
const data =
typeof opts.status === "function" ? opts.status() : (opts.status ?? { enabled: false, connected: false })
return { data }
},
enable: async (_body?: unknown, _opts?: unknown) => {
if (opts.fail) throw new Error("enable failed")
return { data: true }
},
disable: async (_body?: unknown, _opts?: unknown) => {
if (opts.fail) throw new Error("disable failed")
return { data: true }
},
},
}
}
function service() {
return new RemoteStatusService()
}
// ---------------------------------------------------------------------------
// Listener management
// ---------------------------------------------------------------------------
describe("RemoteStatusService", () => {
describe("onChange", () => {
it("listener called on state change", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
await svc.refresh()
expect(states).toEqual([{ enabled: true, connected: true }])
svc.dispose()
})
it("listener not called after unsubscribe", async () => {
const svc = service()
const states: RemoteState[] = []
const unsub = svc.onChange((s) => states.push(s))
unsub()
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
await svc.refresh()
expect(states).toEqual([])
svc.dispose()
})
it("multiple listeners all notified", async () => {
const svc = service()
const a: RemoteState[] = []
const b: RemoteState[] = []
svc.onChange((s) => a.push(s))
svc.onChange((s) => b.push(s))
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.refresh()
expect(a).toEqual([{ enabled: true, connected: false }])
expect(b).toEqual([{ enabled: true, connected: false }])
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// refresh()
// ---------------------------------------------------------------------------
describe("refresh", () => {
it("fetches status and notifies listeners", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.refresh()
expect(states).toEqual([{ enabled: true, connected: false }])
svc.dispose()
})
it("without client is a no-op", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
await svc.refresh() // no client set
expect(states).toEqual([])
svc.dispose()
})
it("does not notify if state unchanged", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
// initial state is { enabled: false, connected: false }, same as client returns
svc.setClient(client({ status: { enabled: false, connected: false } }) as never)
await svc.refresh()
expect(states).toEqual([]) // no change from initial
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// setEnabled()
// ---------------------------------------------------------------------------
describe("setEnabled", () => {
it("setEnabled(true) calls enable and broadcasts enabled state", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.setEnabled(true)
expect(states).toEqual([{ enabled: true, connected: false }])
svc.dispose()
})
it("setEnabled(false) calls disable and broadcasts disabled", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
// First get to enabled state
await svc.refresh()
states.length = 0 // reset
await svc.setEnabled(false)
expect(states).toEqual([{ enabled: false, connected: false }])
svc.dispose()
})
it("setEnabled(false) after enable broadcasts disabled", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.setEnabled(true)
states.length = 0
await svc.setEnabled(false)
expect(states).toEqual([{ enabled: false, connected: false }])
svc.dispose()
})
it("setEnabled(true) error is surfaced", async () => {
const svc = service()
svc.setClient(client({ fail: true }) as never)
await expect(svc.setEnabled(true)).rejects.toThrow("enable failed")
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// toggle()
// ---------------------------------------------------------------------------
describe("toggle", () => {
it("toggle when disabled calls enable", async () => {
const svc = service()
let enabled = false
const c = {
remote: {
status: async (_b?: unknown, _o?: unknown) => ({ data: { enabled: false, connected: false } }),
enable: async (_b?: unknown, _o?: unknown) => {
enabled = true
return { data: true }
},
disable: async (_b?: unknown, _o?: unknown) => ({ data: true }),
},
}
svc.setClient(c as never)
await svc.toggle()
expect(enabled).toBe(true)
svc.dispose()
})
it("toggle when enabled calls disable", async () => {
const svc = service()
let disabled = false
const c = {
remote: {
status: async (_b?: unknown, _o?: unknown) => ({ data: { enabled: true, connected: true } }),
enable: async (_b?: unknown, _o?: unknown) => ({ data: true }),
disable: async (_b?: unknown, _o?: unknown) => {
disabled = true
return { data: true }
},
},
}
svc.setClient(c as never)
await svc.toggle()
expect(disabled).toBe(true)
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// Push-based updates via updateFromEvent
// ---------------------------------------------------------------------------
describe("updateFromEvent", () => {
it("broadcasts state when pushed via event", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.updateFromEvent({ enabled: true, connected: true })
expect(states).toEqual([{ enabled: true, connected: true }])
svc.dispose()
})
it("does not notify if event state matches current", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
// initial state is { enabled: false, connected: false }
svc.updateFromEvent({ enabled: false, connected: false })
expect(states).toEqual([])
svc.dispose()
})
it("tracks successive event-driven transitions", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.updateFromEvent({ enabled: true, connected: false })
svc.updateFromEvent({ enabled: true, connected: true })
expect(states).toEqual([
{ enabled: true, connected: false },
{ enabled: true, connected: true },
])
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// clearState
// ---------------------------------------------------------------------------
describe("clearState", () => {
it("resets to disabled state", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.updateFromEvent({ enabled: true, connected: true })
states.length = 0
svc.clearState()
expect(states).toEqual([{ enabled: false, connected: false }])
expect(svc.getState()).toEqual({ enabled: false, connected: false })
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// Status bar
// ---------------------------------------------------------------------------
describe("status bar", () => {
it("status bar hidden when remote disabled", async () => {
const svc = service()
svc.setClient(client({ status: { enabled: false, connected: false } }) as never)
await svc.refresh() // no state change from initial, bar should stay hidden
// Dispose checks bar was never shown — no direct assertion on mock, just no crash
svc.dispose()
})
it("status bar shown with correct text when connected", async () => {
const svc = service()
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
await svc.refresh()
// Service is functional — status bar is managed internally. We verify no errors.
svc.dispose()
})
it("status bar shown with connecting text when enabled but not connected", async () => {
const svc = service()
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.refresh()
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// dispose()
// ---------------------------------------------------------------------------
describe("dispose", () => {
it("dispose clears listeners", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.updateFromEvent({ enabled: true, connected: false })
svc.dispose()
// No further notifications after dispose
svc.updateFromEvent({ enabled: true, connected: true })
expect(states).toEqual([{ enabled: true, connected: false }])
})
})
})
@@ -0,0 +1,102 @@
import { describe, it, expect } from "bun:test"
import { Semaphore } from "../../src/agent-manager/semaphore"
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
describe("Semaphore", () => {
it("runs tasks up to the concurrency limit", async () => {
const sem = new Semaphore(2)
let running = 0
let peak = 0
const task = () =>
sem.run(async () => {
running++
peak = Math.max(peak, running)
await delay(50)
running--
})
await Promise.all([task(), task(), task(), task(), task()])
expect(peak).toBe(2)
expect(running).toBe(0)
})
it("returns the value produced by the function", async () => {
const sem = new Semaphore(1)
const result = await sem.run(async () => 42)
expect(result).toBe(42)
})
it("propagates rejections without blocking the queue", async () => {
const sem = new Semaphore(1)
const order: string[] = []
const failing = sem.run(async () => {
order.push("fail-start")
throw new Error("boom")
})
const passing = sem.run(async () => {
order.push("pass-start")
return "ok"
})
await expect(failing).rejects.toThrow("boom")
expect(await passing).toBe("ok")
expect(order).toEqual(["fail-start", "pass-start"])
})
it("processes queued tasks in FIFO order", async () => {
const sem = new Semaphore(1)
const order: number[] = []
// First task holds the slot while 2 and 3 queue
const t1 = sem.run(async () => {
order.push(1)
await delay(50)
})
const t2 = sem.run(async () => {
order.push(2)
})
const t3 = sem.run(async () => {
order.push(3)
})
await Promise.all([t1, t2, t3])
expect(order).toEqual([1, 2, 3])
})
it("allows full concurrency when limit exceeds task count", async () => {
const sem = new Semaphore(10)
let running = 0
let peak = 0
const task = () =>
sem.run(async () => {
running++
peak = Math.max(peak, running)
await delay(30)
running--
})
await Promise.all([task(), task(), task()])
expect(peak).toBe(3)
})
it("releases the slot on synchronous throw", async () => {
const sem = new Semaphore(1)
await expect(
sem.run(() => {
throw new Error("sync")
}),
).rejects.toThrow("sync")
// Slot is free — next task should run immediately
const result = await sem.run(async () => "recovered")
expect(result).toBe("recovered")
})
})
@@ -96,6 +96,15 @@ describe("WorktreeStateManager", () => {
expect(manager.getSession("s1")?.worktreeId).toBe(wt2.id)
})
it("moves session back to local (null worktreeId)", () => {
const wt = manager.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
manager.addSession("s1", wt.id)
expect(manager.getSession("s1")?.worktreeId).toBe(wt.id)
manager.moveSession("s1", null)
expect(manager.getSession("s1")?.worktreeId).toBeNull()
})
it("moveSession is a no-op for nonexistent session", () => {
manager.moveSession("nonexistent", "wt-1")
expect(manager.getSessions()).toHaveLength(0)
@@ -122,6 +122,17 @@ describe("WorktreeStateManager sections", () => {
})
})
describe("setWorktreeOrder", () => {
it("preserves sections missing from incoming order", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
const a = mgr.addSection("A", null)
const b = mgr.addSection("B", null)
// Simulate webview sending an order that omits section B
mgr.setWorktreeOrder([wt.id, a.id])
expect(mgr.getWorktreeOrder()).toContain(b.id)
})
})
describe("moveToSection", () => {
it("sets sectionId and removes from worktreeOrder", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
@@ -220,6 +231,16 @@ describe("WorktreeStateManager sections", () => {
expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(a.id)
})
it("moves a section that is missing from worktreeOrder", () => {
const a = mgr.addSection("A", null)
const b = mgr.addSection("B", null)
// Simulate a drag-and-drop that lost section B from the order
mgr.setWorktreeOrder([a.id])
expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id])
mgr.moveSection(b.id, -1)
expect(mgr.getWorktreeOrder()).toEqual([b.id, a.id])
})
it("persists reordered sections across save/load", async () => {
const a = mgr.addSection("A", null)
const b = mgr.addSection("B", null)
@@ -81,7 +81,7 @@ import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { LanguageBridge, DataBridge } from "../src/App"
import { useLanguage } from "../src/context/language"
import { formatRelativeDate } from "../src/utils/date"
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, LOCAL } from "./navigate"
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, restoreLocalSessions, LOCAL } from "./navigate"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
import { ConstrainDragYAxis, SortableReviewTab, SortableTab } from "./sortable-tab"
import { DiffPanel } from "./DiffPanel"
@@ -106,6 +106,7 @@ import {
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { mergeWorktreeDiffs } from "./diff-state"
import { trackOpenSessions } from "./open-sessions"
import "./agent-manager.css"
import "./agent-manager-review.css"
@@ -671,11 +672,17 @@ const AgentManagerContent: Component = () => {
const all = session.sessions()
if (all.length === 0) return // sessions not loaded yet
const ids = all.map((s) => s.id)
const valid = localSessionIDs().filter((lid) => isPending(lid) || validateLocalSession(lid, ids))
if (valid.length !== localSessionIDs().length) {
const prev = localSessionIDs()
const valid = prev.filter((lid) => isPending(lid) || validateLocalSession(lid, ids))
if (valid.length !== prev.length) {
const removed = prev.filter((lid) => !isPending(lid) && !valid.includes(lid))
for (const id of removed) {
vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id })
}
setLocalSessionIDs(valid)
}
})
trackOpenSessions(localSessionIDs, isPending, managedSessions, vscode.postMessage)
// Drop in-memory review state for worktrees that no longer exist.
createEffect(() => {
@@ -1118,6 +1125,7 @@ const AgentManagerContent: Component = () => {
setLocalSessionIDs((prev) => [...prev, created.session.id])
setSelection(LOCAL)
}
vscode.postMessage({ type: "agentManager.persistSession", sessionId: created.session.id })
session.selectSession(created.session.id)
})
@@ -1192,6 +1200,7 @@ const AgentManagerContent: Component = () => {
if (idx >= 0) return [...prev.slice(0, idx + 1), ev.sessionId, ...prev.slice(idx + 1)]
return [...prev, ev.sessionId]
})
vscode.postMessage({ type: "agentManager.persistSession", sessionId: ev.sessionId })
}
session.selectSession(ev.sessionId)
}
@@ -1230,15 +1239,15 @@ const AgentManagerContent: Component = () => {
const ms = state.sessions.find((s) => s.id === current)
if (ms?.worktreeId) setSelection(ms.worktreeId)
}
// Recover local tab order from persisted state
const localOrder = state.tabOrder?.[LOCAL]
if (localOrder && localSessionIDs().length > 0) {
const reordered = applyTabOrder(
localSessionIDs().map((id) => ({ id })),
localOrder,
).map((item) => item.id)
setLocalSessionIDs(reordered)
}
// Restore local session IDs from persisted state (sessions with no worktreeId)
const restored = restoreLocalSessions(
state.sessions,
localSessionIDs(),
state.tabOrder?.[LOCAL],
isPending,
applyTabOrder,
)
if (restored) setLocalSessionIDs(restored)
// Recover sessions collapsed state from extension-persisted state
if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed)
// Clear busy state for worktrees that have been removed
@@ -1889,6 +1898,9 @@ const AgentManagerContent: Component = () => {
}
if (pending || localSet().has(sessionId)) {
setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId))
if (!pending) {
vscode.postMessage({ type: "agentManager.forgetSession", sessionId })
}
} else {
vscode.postMessage({ type: "agentManager.closeSession", sessionId })
}
@@ -2416,7 +2428,9 @@ const AgentManagerContent: Component = () => {
</SectionHeader>
)
}
return renderWt(item.wt, idx)
const ug = ungrouped()
const wtIdx = () => ug.indexOf(item.wt)
return renderWt(item.wt, wtIdx, ug)
}}
</For>
)
@@ -54,6 +54,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "افتح مجلدًا يحتوي على مستودع git لاستخدام مساحات العمل (worktrees).",
"agentManager.setup.error.lfs_missing":
"يستخدم هذا المستودع Git LFS، ولكن لم يتم العثور على git-lfs. يرجى تثبيت Git LFS.",
"agentManager.setup.error.no_commits":
"هذا المستودع لا يحتوي على أي التزامات (commits) بعد. قم بإنشاء التزام أولي قبل استخدام مساحات العمل (worktrees).",
"agentManager.shortcuts.title": "اختصارات لوحة المفاتيح",
"agentManager.shortcuts.category.sidebar": "الشريط الجانبي",
"agentManager.shortcuts.category.tabs": "علامات التبويب",
@@ -55,6 +55,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Abra uma pasta que contém um repositório git para usar worktrees.",
"agentManager.setup.error.lfs_missing":
"Este repositório usa Git LFS, mas o git-lfs não foi encontrado. Instale o Git LFS.",
"agentManager.setup.error.no_commits":
"Este repositório ainda não possui commits. Crie um commit inicial antes de usar worktrees.",
"agentManager.shortcuts.title": "Atalhos de Teclado",
"agentManager.shortcuts.category.sidebar": "Barra lateral",
"agentManager.shortcuts.category.tabs": "Abas",
@@ -56,6 +56,8 @@ export const dict = {
"Otvorite fasciklu koja sadrži git repozitorijum da biste koristili worktrees.",
"agentManager.setup.error.lfs_missing":
"Ovaj repozitorijum koristi Git LFS, ali git-lfs nije pronađen. Molimo instalirajte Git LFS.",
"agentManager.setup.error.no_commits":
"Ovaj repozitorijum još uvek nema commit-ova. Napravite početni commit pre korišćenja worktrees.",
"agentManager.shortcuts.title": "Prečice na tastaturi",
"agentManager.shortcuts.category.sidebar": "Bočna traka",
"agentManager.shortcuts.category.tabs": "Kartice",
@@ -55,6 +55,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Åbn en mappe, der indeholder et git-repository for at bruge worktrees.",
"agentManager.setup.error.lfs_missing":
"Dette repository bruger Git LFS, men git-lfs blev ikke fundet. Installer venligst Git LFS.",
"agentManager.setup.error.no_commits":
"Dette repository har ingen commits endnu. Opret et indledende commit før du bruger worktrees.",
"agentManager.shortcuts.title": "Tastaturgenveje",
"agentManager.shortcuts.category.sidebar": "Sidebjælke",
"agentManager.shortcuts.category.tabs": "Faner",
@@ -56,6 +56,8 @@ export const dict = {
"Öffnen Sie einen Ordner, der ein Git-Repository enthält, um Worktrees zu verwenden.",
"agentManager.setup.error.lfs_missing":
"Dieses Repository verwendet Git LFS, aber git-lfs wurde nicht gefunden. Bitte installieren Sie Git LFS.",
"agentManager.setup.error.no_commits":
"Dieses Repository hat noch keine Commits. Erstellen Sie einen initialen Commit, bevor Sie Worktrees verwenden.",
"agentManager.shortcuts.title": "Tastenkombinationen",
"agentManager.shortcuts.category.sidebar": "Seitenleiste",
"agentManager.shortcuts.category.tabs": "Tabs",
@@ -60,6 +60,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Open a folder that contains a git repository to use worktrees.",
"agentManager.setup.error.lfs_missing":
"This repository uses Git LFS, but git-lfs was not found. Please install Git LFS.",
"agentManager.setup.error.no_commits":
"This repository has no commits yet. Create an initial commit before using worktrees.",
"agentManager.shortcuts.title": "Keyboard Shortcuts",
"agentManager.shortcuts.category.sidebar": "Sidebar",
"agentManager.shortcuts.category.tabs": "Tabs",
@@ -55,6 +55,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Abra una carpeta que contenga un repositorio git para usar worktrees.",
"agentManager.setup.error.lfs_missing":
"Este repositorio usa Git LFS, pero no se encontró git-lfs. Por favor instale Git LFS.",
"agentManager.setup.error.no_commits":
"Este repositorio aún no tiene commits. Cree un commit inicial antes de usar worktrees.",
"agentManager.shortcuts.title": "Atajos de teclado",
"agentManager.shortcuts.category.sidebar": "Barra lateral",
"agentManager.shortcuts.category.tabs": "Pestañas",
@@ -55,6 +55,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Ouvrez un dossier contenant un dépôt git pour utiliser les worktrees.",
"agentManager.setup.error.lfs_missing":
"Ce dépôt utilise Git LFS, mais git-lfs n'a pas été trouvé. Veuillez installer Git LFS.",
"agentManager.setup.error.no_commits":
"Ce dépôt n'a pas encore de commits. Créez un commit initial avant d'utiliser les worktrees.",
"agentManager.shortcuts.title": "Raccourcis clavier",
"agentManager.shortcuts.category.sidebar": "Barre latérale",
"agentManager.shortcuts.category.tabs": "Onglets",
@@ -55,6 +55,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "worktreesを使用するには、gitリポジトリを含むフォルダーを開いてください。",
"agentManager.setup.error.lfs_missing":
"このリポジトリはGit LFSを使用していますが、git-lfsが見つかりませんでした。Git LFSをインストールしてください。",
"agentManager.setup.error.no_commits":
"このリポジトリにはまだコミットがありません。worktreesを使用する前に最初のコミットを作成してください。",
"agentManager.shortcuts.title": "キーボードショートカット",
"agentManager.shortcuts.category.sidebar": "サイドバー",
"agentManager.shortcuts.category.tabs": "タブ",
@@ -55,6 +55,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "worktrees를 사용하려면 git 리포지토리가 포함된 폴더를 여세요.",
"agentManager.setup.error.lfs_missing":
"이 리포지토리는 Git LFS를 사용하지만 git-lfs를 찾을 수 없습니다. Git LFS를 설치하세요.",
"agentManager.setup.error.no_commits":
"이 리포지토리에는 아직 커밋이 없습니다. worktrees를 사용하기 전에 초기 커밋을 생성하세요.",
"agentManager.shortcuts.title": "키보드 단축키",
"agentManager.shortcuts.category.sidebar": "사이드바",
"agentManager.shortcuts.category.tabs": "탭",
@@ -60,6 +60,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Open een map die een git repository bevat om worktrees te gebruiken.",
"agentManager.setup.error.lfs_missing":
"Deze repository gebruikt Git LFS, maar git-lfs is niet gevonden. Installeer Git LFS.",
"agentManager.setup.error.no_commits":
"Deze repository heeft nog geen commits. Maak een initiële commit voordat je worktrees gebruikt.",
"agentManager.shortcuts.title": "Sneltoetsen",
"agentManager.shortcuts.category.sidebar": "Zijbalk",
"agentManager.shortcuts.category.tabs": "Tabbladen",
@@ -55,6 +55,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Åpne en mappe som inneholder et git-repositorium for å bruke worktrees.",
"agentManager.setup.error.lfs_missing":
"Dette repositoriet bruker Git LFS, men git-lfs ble ikke funnet. Vennligst installer Git LFS.",
"agentManager.setup.error.no_commits":
"Dette repositoriet har ingen commits ennå. Opprett en første commit før du bruker worktrees.",
"agentManager.shortcuts.title": "Tastatursnarveier",
"agentManager.shortcuts.category.sidebar": "Sidepanel",
"agentManager.shortcuts.category.tabs": "Faner",
@@ -55,6 +55,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Otwórz folder zawierający repozytorium git, aby używać worktrees.",
"agentManager.setup.error.lfs_missing":
"To repozytorium używa Git LFS, ale nie znaleziono git-lfs. Zainstaluj Git LFS.",
"agentManager.setup.error.no_commits":
"To repozytorium nie ma jeszcze commitów. Utwórz początkowy commit przed użyciem worktrees.",
"agentManager.shortcuts.title": "Skróty klawiszowe",
"agentManager.shortcuts.category.sidebar": "Pasek boczny",
"agentManager.shortcuts.category.tabs": "Karty",
@@ -55,6 +55,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Откройте папку, содержащую репозиторий git, чтобы использовать worktrees.",
"agentManager.setup.error.lfs_missing":
"Этот репозиторий использует Git LFS, но git-lfs не найден. Пожалуйста, установите Git LFS.",
"agentManager.setup.error.no_commits":
"В этом репозитории еще нет коммитов. Создайте начальный коммит перед использованием worktrees.",
"agentManager.shortcuts.title": "Сочетания клавиш",
"agentManager.shortcuts.category.sidebar": "Боковая панель",
"agentManager.shortcuts.category.tabs": "Вкладки",
@@ -53,6 +53,7 @@ export const dict = {
"agentManager.setup.error.git_not_found": "ไม่ได้ติดตั้ง Git หรือไม่พบใน PATH โปรดติดตั้ง Git และรีสตาร์ท VS Code",
"agentManager.setup.error.not_git_repo": "เปิดโฟลเดอร์ที่มีที่เก็บ git เพื่อใช้ worktrees",
"agentManager.setup.error.lfs_missing": "ที่เก็บนี้ใช้ Git LFS แต่ไม่พบ git-lfs โปรดติดตั้ง Git LFS",
"agentManager.setup.error.no_commits": "ที่เก็บนี้ยังไม่มีการคอมมิต สร้างการคอมมิตเริ่มต้นก่อนใช้ worktrees",
"agentManager.shortcuts.title": "ปุ่มลัดแป้นพิมพ์",
"agentManager.shortcuts.category.sidebar": "แถบด้านข้าง",
"agentManager.shortcuts.category.tabs": "แท็บ",
@@ -60,6 +60,8 @@ export const dict = {
"agentManager.setup.error.not_git_repo": "Worktree'leri kullanmak için bir git deposu içeren bir klasör açın.",
"agentManager.setup.error.lfs_missing":
"Bu depo Git LFS kullanıyor, ancak git-lfs bulunamadı. Lütfen Git LFS'yi yükleyin.",
"agentManager.setup.error.no_commits":
"Bu depoda henüz commit bulunmuyor. Worktree'leri kullanmadan önce bir başlangıç commit'i oluşturun.",
"agentManager.shortcuts.title": "Klavye Kısayolları",
"agentManager.shortcuts.category.sidebar": "Kenar Çubuğu",
"agentManager.shortcuts.category.tabs": "Sekmeler",
@@ -61,6 +61,8 @@ export const dict = {
"Відкрийте папку, що містить git-репозиторій, щоб використовувати робочі дерева.",
"agentManager.setup.error.lfs_missing":
"Цей репозиторій використовує Git LFS, але git-lfs не знайдено. Будь ласка, встановіть Git LFS.",
"agentManager.setup.error.no_commits":
"У цьому репозиторії ще немає коммітів. Створіть початковий комміт перед використанням worktrees.",
"agentManager.shortcuts.title": "Клавіатурні скорочення",
"agentManager.shortcuts.category.sidebar": "Бічна панель",
"agentManager.shortcuts.category.tabs": "Вкладки",
@@ -53,6 +53,7 @@ export const dict = {
"agentManager.setup.error.git_not_found": "未安装 Git 或在 PATH 中找不到 Git。请安装 Git 并重新启动 VS Code。",
"agentManager.setup.error.not_git_repo": "打开一个包含 git 存储库的文件夹以使用 worktrees。",
"agentManager.setup.error.lfs_missing": "此存储库使用 Git LFS,但找不到 git-lfs。请安装 Git LFS。",
"agentManager.setup.error.no_commits": "此存储库尚无提交。在使用 worktrees 之前,请创建一个初始提交。",
"agentManager.shortcuts.title": "键盘快捷键",
"agentManager.shortcuts.category.sidebar": "侧边栏",
"agentManager.shortcuts.category.tabs": "标签页",
@@ -53,6 +53,7 @@ export const dict = {
"agentManager.setup.error.git_not_found": "未安裝 Git 或在 PATH 中找不到 Git。請安裝 Git 並重新啟動 VS Code。",
"agentManager.setup.error.not_git_repo": "開啟一個包含 git 儲存庫的資料夾以使用 worktrees。",
"agentManager.setup.error.lfs_missing": "此儲存庫使用 Git LFS,但找不到 git-lfs。請安裝 Git LFS。",
"agentManager.setup.error.no_commits": "此儲存庫尚無提交。在使用 worktrees 之前,請建立一個初始提交。",
"agentManager.shortcuts.title": "鍵盤快捷鍵",
"agentManager.shortcuts.category.sidebar": "側邊欄",
"agentManager.shortcuts.category.tabs": "分頁",
@@ -74,6 +74,54 @@ export function adjacentHint(
return ""
}
/**
* Compute which session IDs should populate the "local" tab on state restore.
*
* Managed sessions with `worktreeId === null` are non-worktree sessions that
* were persisted to agent-manager.json. On restore we use them as the local
* tab list, optionally applying a persisted tab order.
*
* @param sessions - All managed sessions from agent-manager.json
* @param current - The webview's current localSessionIDs (may contain pending tabs)
* @param tabOrder - Persisted tab order for the "local" key, if any
* @param isPending - Predicate to identify pending (not-yet-created) tab IDs
* @param applyOrder - Reorder helper: (items, order) ordered items
*/
export function restoreLocalSessions(
sessions: { id: string; worktreeId: string | null }[],
current: string[],
tabOrder: string[] | undefined,
isPending: (id: string) => boolean,
applyOrder: (items: { id: string }[], order: string[]) => { id: string }[],
): string[] | undefined {
const locals = sessions.filter((s) => !s.worktreeId).map((s) => s.id)
const real = current.filter((id) => !isPending(id))
// First restore: current has no real sessions but disk has some
if (locals.length > 0 && real.length === 0) {
if (!tabOrder) return locals
return applyOrder(
locals.map((id) => ({ id })),
tabOrder,
).map((item) => item.id)
}
// Merge any disk-persisted sessions missing from current (e.g. vscode.setState
// debounce didn't fire before close, but persistSession already wrote to disk)
const missing = locals.filter((id) => !current.includes(id))
const merged = missing.length > 0 ? [...current, ...missing] : current
// Apply tab order if present
if (tabOrder && merged.length > 0) {
return applyOrder(
merged.map((id) => ({ id })),
tabOrder,
).map((item) => item.id)
}
return missing.length > 0 ? merged : undefined
}
/**
* After removing a worktree, pick the nearest remaining sidebar neighbor.
* Order: the worktree just below the one above LOCAL.
@@ -0,0 +1,15 @@
import { createEffect } from "solid-js"
import type { Accessor } from "solid-js"
/** Reactive effect: reports open (non-pending) session IDs to the extension for heartbeat. */
export function trackOpenSessions(
local: Accessor<string[]>,
pending: (id: string) => boolean,
managed: Accessor<Array<{ id: string }>>,
post: (msg: { type: "agentManager.openSessions"; sessionIDs: string[] }) => void,
): void {
createEffect(() => {
const ids = [...new Set([...local().filter((id) => !pending(id)), ...managed().map((s) => s.id)])]
post({ type: "agentManager.openSessions", sessionIDs: ids })
})
}
@@ -1,10 +1,12 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Switch } from "@kilocode/kilo-ui/switch"
import { Select } from "@kilocode/kilo-ui/select"
import { TextField } from "@kilocode/kilo-ui/text-field"
import { Card } from "@kilocode/kilo-ui/card"
import { useConfig } from "../../context/config"
import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import type { ExtensionMessage } from "../../types/messages"
import SettingsRow from "./SettingsRow"
interface ShareOption {
@@ -21,6 +23,20 @@ const SHARE_OPTIONS: ShareOption[] = [
const ExperimentalTab: Component = () => {
const { config, updateConfig } = useConfig()
const language = useLanguage()
const vscode = useVSCode()
const [active, setActive] = createSignal(false)
const handler = (msg: ExtensionMessage) => {
if (msg.type === "remoteStatus") {
setActive(msg.enabled)
}
}
onMount(() => {
const unsub = vscode.onMessage(handler)
vscode.postMessage({ type: "requestRemoteStatus" })
onCleanup(unsub)
})
const experimental = createMemo(() => config().experimental ?? {})
@@ -33,6 +49,37 @@ const ExperimentalTab: Component = () => {
return (
<div>
<Card>
{/* Remote control */}
<div data-component="remote-settings">
<div data-slot="remote-settings-header">
<div data-slot="settings-row-label-title">{language.t("settings.experimental.remote.title")}</div>
<div data-slot="settings-row-label-subtitle">{language.t("settings.experimental.remote.description")}</div>
</div>
<div data-slot="remote-settings-block">
<div data-slot="remote-settings-row">
<span data-slot="remote-settings-label">{language.t("settings.experimental.remote.current")}</span>
<span data-slot="remote-settings-status" data-active={active()}>
{active()
? language.t("settings.experimental.remote.active")
: language.t("settings.experimental.remote.inactive")}
</span>
</div>
<div data-slot="remote-settings-hint">{language.t("settings.experimental.remote.hint")}</div>
</div>
<div data-slot="remote-settings-row">
<span data-slot="remote-settings-label">{language.t("settings.experimental.remote.startup")}</span>
<Switch
checked={config().remote_control ?? false}
onChange={(checked) => {
updateConfig({ remote_control: checked })
}}
hideLabel
>
{language.t("settings.experimental.remote.startup")}
</Switch>
</div>
</div>
{/* Share mode */}
<SettingsRow
title={language.t("settings.experimental.share.title")}
@@ -22,6 +22,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [
"instructions",
"skills",
"snapshot",
"remote_control",
"share",
"username",
"watcher",
@@ -98,6 +98,14 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
vscode.postMessage({ type: "openSettingsPanel" })
},
},
{
name: "remote",
description: "Toggle remote control",
hints: [],
action: () => {
vscode.postMessage({ type: "toggleRemote" })
},
},
]
const client = exclude ? all.filter((c) => !exclude.has(c.name)) : all
+8
View File
@@ -1056,6 +1056,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "متابعة حلقة الوكيل عند رفض الإذن",
"settings.experimental.mcpTimeout.title": "مهلة MCP (مللي ثانية)",
"settings.experimental.mcpTimeout.description": "مهلة طلبات خادم MCP بالمللي ثانية",
"settings.experimental.remote.title": "التحكم Remote",
"settings.experimental.remote.description":
"قم بتمكين التحكم Remote في الجلسات عبر Kilo Cloud. سيؤثر هذا أيضًا على واجهات سطر الأوامر (CLIs) على هذا الجهاز.",
"settings.experimental.remote.current": "الحالة الحالية:",
"settings.experimental.remote.startup": "التفعيل التلقائي عند بدء التشغيل:",
"settings.experimental.remote.active": "نشط",
"settings.experimental.remote.inactive": "غير نشط",
"settings.experimental.remote.hint": "استخدم /remote في الدردشة للتبديل",
"settings.experimental.toolToggles": "مفاتيح الأدوات",
"settings.agentBehaviour.defaultAgent.title": "الوكيل الافتراضي",
"settings.agentBehaviour.defaultAgent.description": "الوكيل المستخدم عند عدم التحديد",
+8
View File
@@ -1073,6 +1073,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Continuar o loop do agente quando uma permissão é negada",
"settings.experimental.mcpTimeout.title": "Tempo limite MCP (ms)",
"settings.experimental.mcpTimeout.description": "Tempo limite para solicitações do servidor MCP em milissegundos",
"settings.experimental.remote.title": "Controle Remote",
"settings.experimental.remote.description":
"Habilite o controle Remote de sessões via Kilo Cloud. Isso também afetará as CLIs nesta máquina.",
"settings.experimental.remote.current": "Estado atual:",
"settings.experimental.remote.startup": "Ativar automaticamente na inicialização:",
"settings.experimental.remote.active": "Ativo",
"settings.experimental.remote.inactive": "Inativo",
"settings.experimental.remote.hint": "Use /remote no chat para alternar",
"settings.experimental.toolToggles": "Alternadores de ferramentas",
"settings.agentBehaviour.defaultAgent.title": "Agente padrão",
"settings.agentBehaviour.defaultAgent.description": "Agente a usar quando nenhum é especificado",
+8
View File
@@ -1072,6 +1072,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Nastavi petlju agenta kada je dozvola odbijena",
"settings.experimental.mcpTimeout.title": "MCP istek vremena (ms)",
"settings.experimental.mcpTimeout.description": "Istek vremena za MCP server zahtjeve u milisekundama",
"settings.experimental.remote.title": "Remote kontrola",
"settings.experimental.remote.description":
"Omogućite Remote kontrolu sesija putem Kilo Cloud. Ovo će također utjecati na CLI-jeve na ovoj mašini.",
"settings.experimental.remote.current": "Trenutno stanje:",
"settings.experimental.remote.startup": "Automatsko uključivanje pri pokretanju:",
"settings.experimental.remote.active": "Aktivno",
"settings.experimental.remote.inactive": "Neaktivno",
"settings.experimental.remote.hint": "Koristite /remote u chatu za prebacivanje",
"settings.experimental.toolToggles": "Prekidači alata",
"settings.agentBehaviour.defaultAgent.title": "Zadani agent",
"settings.agentBehaviour.defaultAgent.description": "Agent koji se koristi kada nijedan nije naveden",
+8
View File
@@ -1066,6 +1066,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Fortsæt agentløkken, når en tilladelse afvises",
"settings.experimental.mcpTimeout.title": "MCP-timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout for MCP-serveranmodninger i millisekunder",
"settings.experimental.remote.title": "Remote-styring",
"settings.experimental.remote.description":
"Aktivér Remote-styring af sessioner via Kilo Cloud. Dette vil også påvirke CLI'er på denne maskine.",
"settings.experimental.remote.current": "Nuværende status:",
"settings.experimental.remote.startup": "Aktivér automatisk ved opstart:",
"settings.experimental.remote.active": "Aktiv",
"settings.experimental.remote.inactive": "Inaktiv",
"settings.experimental.remote.hint": "Brug /remote i chatten for at skifte",
"settings.experimental.toolToggles": "Værktøjsskift",
"settings.agentBehaviour.defaultAgent.title": "Standardagent",
"settings.agentBehaviour.defaultAgent.description": "Agent til brug, når ingen er angivet",
+8
View File
@@ -1086,6 +1086,14 @@ export const dict = {
"Agent-Schleife fortsetzen, wenn eine Berechtigung abgelehnt wird",
"settings.experimental.mcpTimeout.title": "MCP-Zeitlimit (ms)",
"settings.experimental.mcpTimeout.description": "Zeitlimit für MCP-Server-Anfragen in Millisekunden",
"settings.experimental.remote.title": "Remote-Steuerung",
"settings.experimental.remote.description":
"Aktivieren Sie die Remote-Steuerung von Sitzungen über Kilo Cloud. Dies betrifft auch CLIs auf diesem Computer.",
"settings.experimental.remote.current": "Aktueller Status:",
"settings.experimental.remote.startup": "Automatisch beim Start aktivieren:",
"settings.experimental.remote.active": "Aktiv",
"settings.experimental.remote.inactive": "Inaktiv",
"settings.experimental.remote.hint": "Verwende /remote im Chat zum Umschalten",
"settings.experimental.toolToggles": "Werkzeug-Schalter",
"settings.agentBehaviour.defaultAgent.title": "Standard-Agent",
"settings.agentBehaviour.defaultAgent.description": "Agent, der verwendet wird, wenn keiner angegeben ist",
@@ -1067,6 +1067,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied",
"settings.experimental.mcpTimeout.title": "MCP Timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout for MCP server requests in milliseconds",
"settings.experimental.remote.title": "Remote Control",
"settings.experimental.remote.description":
"Enable remote control of sessions via Kilo Cloud. This will also affect CLIs on this machine.",
"settings.experimental.remote.current": "Current state:",
"settings.experimental.remote.startup": "Auto-enable on startup:",
"settings.experimental.remote.active": "Active",
"settings.experimental.remote.inactive": "Inactive",
"settings.experimental.remote.hint": "Use /remote in chat to toggle",
"settings.experimental.toolToggles": "Tool Toggles",
"settings.agentBehaviour.defaultAgent.title": "Default Agent",
+8
View File
@@ -1077,6 +1077,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Continuar el bucle del agente cuando se deniega un permiso",
"settings.experimental.mcpTimeout.title": "Tiempo de espera MCP (ms)",
"settings.experimental.mcpTimeout.description": "Tiempo de espera para solicitudes del servidor MCP en milisegundos",
"settings.experimental.remote.title": "Control Remote",
"settings.experimental.remote.description":
"Habilite el control Remote de las sesiones a través de Kilo Cloud. Esto también afectará a las CLI de este equipo.",
"settings.experimental.remote.current": "Estado actual:",
"settings.experimental.remote.startup": "Activar automáticamente al inicio:",
"settings.experimental.remote.active": "Activo",
"settings.experimental.remote.inactive": "Inactivo",
"settings.experimental.remote.hint": "Usa /remote en el chat para alternar",
"settings.experimental.toolToggles": "Interruptores de herramientas",
"settings.agentBehaviour.defaultAgent.title": "Agente predeterminado",
"settings.agentBehaviour.defaultAgent.description": "Agente a usar cuando no se especifica ninguno",
+8
View File
@@ -1088,6 +1088,14 @@ export const dict = {
"Continuer la boucle de l'agent lorsqu'une autorisation est refusée",
"settings.experimental.mcpTimeout.title": "Délai MCP (ms)",
"settings.experimental.mcpTimeout.description": "Délai des requêtes du serveur MCP en millisecondes",
"settings.experimental.remote.title": "Contrôle Remote",
"settings.experimental.remote.description":
"Activez le contrôle Remote des sessions via Kilo Cloud. Cela affectera également les CLI sur cette machine.",
"settings.experimental.remote.current": "État actuel :",
"settings.experimental.remote.startup": "Activation automatique au démarrage :",
"settings.experimental.remote.active": "Actif",
"settings.experimental.remote.inactive": "Inactif",
"settings.experimental.remote.hint": "Utilisez /remote dans le chat pour basculer",
"settings.experimental.toolToggles": "Commutateurs d'outils",
"settings.agentBehaviour.defaultAgent.title": "Agent par défaut",
"settings.agentBehaviour.defaultAgent.description": "Agent à utiliser lorsqu'aucun n'est spécifié",
+8
View File
@@ -1066,6 +1066,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "権限が拒否された場合にエージェントループを続行",
"settings.experimental.mcpTimeout.title": "MCPタイムアウト(ミリ秒)",
"settings.experimental.mcpTimeout.description": "MCPサーバーリクエストのタイムアウト(ミリ秒)",
"settings.experimental.remote.title": "Remote コントロール",
"settings.experimental.remote.description":
"Kilo Cloud 経由でのセッションの Remote コントロールを有効にします。これはこのマシンの CLI にも影響します。",
"settings.experimental.remote.current": "現在の状態:",
"settings.experimental.remote.startup": "起動時の自動有効化:",
"settings.experimental.remote.active": "アクティブ",
"settings.experimental.remote.inactive": "非アクティブ",
"settings.experimental.remote.hint": "チャットで /remote を使用して切り替えます",
"settings.experimental.toolToggles": "ツールトグル",
"settings.agentBehaviour.defaultAgent.title": "デフォルトエージェント",
"settings.agentBehaviour.defaultAgent.description": "指定されていない場合に使用するエージェント",
+8
View File
@@ -1063,6 +1063,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "권한이 거부되면 에이전트 루프 계속",
"settings.experimental.mcpTimeout.title": "MCP 타임아웃 (ms)",
"settings.experimental.mcpTimeout.description": "MCP 서버 요청의 타임아웃 시간 (밀리초)",
"settings.experimental.remote.title": "Remote 제어",
"settings.experimental.remote.description":
"Kilo Cloud를 통한 세션의 Remote 제어를 활성화합니다. 이는 이 컴퓨터의 CLI에도 영향을 미칩니다.",
"settings.experimental.remote.current": "현재 상태:",
"settings.experimental.remote.startup": "시작 시 자동 활성화:",
"settings.experimental.remote.active": "활성",
"settings.experimental.remote.inactive": "비활성",
"settings.experimental.remote.hint": "채팅에서 /remote를 사용하여 전환하세요",
"settings.experimental.toolToggles": "도구 토글",
"settings.agentBehaviour.defaultAgent.title": "기본 에이전트",
"settings.agentBehaviour.defaultAgent.description": "지정되지 않은 경우 사용할 에이전트",
+8
View File
@@ -1076,6 +1076,14 @@ export const dict = {
"Ga door met de agent loop wanneer een toestemming wordt geweigerd",
"settings.experimental.mcpTimeout.title": "MCP Timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout voor MCP-serververzoeken in milliseconden",
"settings.experimental.remote.title": "Remote-bediening",
"settings.experimental.remote.description":
"Schakel Remote-bediening van sessies in via Kilo Cloud. Dit heeft ook invloed op CLI's op deze machine.",
"settings.experimental.remote.current": "Huidige status:",
"settings.experimental.remote.startup": "Automatisch inschakelen bij opstarten:",
"settings.experimental.remote.active": "Actief",
"settings.experimental.remote.inactive": "Inactief",
"settings.experimental.remote.hint": "Gebruik /remote in de chat om te schakelen",
"settings.experimental.toolToggles": "Tool Schakelaars",
"settings.agentBehaviour.defaultAgent.title": "Standaard Agent",
+8
View File
@@ -1069,6 +1069,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Fortsett agentløkken når en tillatelse avvises",
"settings.experimental.mcpTimeout.title": "MCP-tidsavbrudd (ms)",
"settings.experimental.mcpTimeout.description": "Tidsavbrudd for MCP-serverforespørsler i millisekunder",
"settings.experimental.remote.title": "Remote-kontroll",
"settings.experimental.remote.description":
"Aktiver Remote-kontroll av økter via Kilo Cloud. Dette vil også påvirke CLI-er på denne maskinen.",
"settings.experimental.remote.current": "Nåværende status:",
"settings.experimental.remote.startup": "Aktiver automatisk ved oppstart:",
"settings.experimental.remote.active": "Aktiv",
"settings.experimental.remote.inactive": "Inaktiv",
"settings.experimental.remote.hint": "Bruk /remote i chatten for å veksle",
"settings.experimental.toolToggles": "Verktøybrytere",
"settings.agentBehaviour.defaultAgent.title": "Standardagent",
"settings.agentBehaviour.defaultAgent.description": "Agent å bruke når ingen er angitt",
+8
View File
@@ -1070,6 +1070,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Kontynuuj pętlę agenta po odmowie uprawnienia",
"settings.experimental.mcpTimeout.title": "Limit czasu MCP (ms)",
"settings.experimental.mcpTimeout.description": "Limit czasu żądań serwera MCP w milisekundach",
"settings.experimental.remote.title": "Sterowanie Remote",
"settings.experimental.remote.description":
"Włącz sterowanie Remote sesjami za pośrednictwem Kilo Cloud. Wpłynie to również na CLI na tej maszynie.",
"settings.experimental.remote.current": "Aktualny stan:",
"settings.experimental.remote.startup": "Automatyczne włączanie przy starcie:",
"settings.experimental.remote.active": "Aktywny",
"settings.experimental.remote.inactive": "Nieaktywny",
"settings.experimental.remote.hint": "Użyj /remote na czacie, aby przełączyć",
"settings.experimental.toolToggles": "Przełączniki narzędzi",
"settings.agentBehaviour.defaultAgent.title": "Domyślny agent",
"settings.agentBehaviour.defaultAgent.description": "Agent używany, gdy żaden nie jest określony",
+8
View File
@@ -1072,6 +1072,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Продолжить цикл агента при отказе в разрешении",
"settings.experimental.mcpTimeout.title": "Таймаут MCP (мс)",
"settings.experimental.mcpTimeout.description": "Таймаут запросов MCP-сервера в миллисекундах",
"settings.experimental.remote.title": "Управление Remote",
"settings.experimental.remote.description":
"Включите управление Remote сеансами через Kilo Cloud. Это также повлияет на CLI на этом компьютере.",
"settings.experimental.remote.current": "Текущее состояние:",
"settings.experimental.remote.startup": "Автоматически включать при запуске:",
"settings.experimental.remote.active": "Активно",
"settings.experimental.remote.inactive": "Неактивно",
"settings.experimental.remote.hint": "Используйте /remote в чате для переключения",
"settings.experimental.toolToggles": "Переключатели инструментов",
"settings.agentBehaviour.defaultAgent.title": "Агент по умолчанию",
"settings.agentBehaviour.defaultAgent.description": "Агент при отсутствии указания",
+8
View File
@@ -1058,6 +1058,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "ดำเนินลูปเอเจนต์ต่อเมื่อสิทธิ์ถูกปฏิเสธ",
"settings.experimental.mcpTimeout.title": "หมดเวลา MCP (มิลลิวินาที)",
"settings.experimental.mcpTimeout.description": "หมดเวลาสำหรับคำขอเซิร์ฟเวอร์ MCP เป็นมิลลิวินาที",
"settings.experimental.remote.title": "การควบคุม Remote",
"settings.experimental.remote.description":
"เปิดใช้งานการควบคุม Remote ของเซสชันผ่าน Kilo Cloud ซึ่งจะส่งผลต่อ CLI บนเครื่องนี้ด้วย",
"settings.experimental.remote.current": "สถานะปัจจุบัน:",
"settings.experimental.remote.startup": "เปิดใช้งานอัตโนมัติเมื่อเริ่มต้น:",
"settings.experimental.remote.active": "เปิดใช้งาน",
"settings.experimental.remote.inactive": "ปิดใช้งาน",
"settings.experimental.remote.hint": "ใช้ /remote ในแชทเพื่อสลับสถานะ",
"settings.experimental.toolToggles": "สวิตช์เครื่องมือ",
"settings.agentBehaviour.defaultAgent.title": "เอเจนต์เริ่มต้น",
"settings.agentBehaviour.defaultAgent.description": "เอเจนต์ที่ใช้เมื่อไม่ได้ระบุ",
+8
View File
@@ -1072,6 +1072,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Bir izin reddedildiğinde ajan döngüsüne devam et",
"settings.experimental.mcpTimeout.title": "MCP Zaman Aşımı (ms)",
"settings.experimental.mcpTimeout.description": "MCP sunucu istekleri için milisaniye cinsinden zaman aşımı",
"settings.experimental.remote.title": "Remote Kontrolü",
"settings.experimental.remote.description":
"Kilo Cloud üzerinden oturumların Remote kontrolünü etkinleştirin. Bu, bu makinedeki CLI'leri de etkileyecektir.",
"settings.experimental.remote.current": "Mevcut durum:",
"settings.experimental.remote.startup": "Başlangıçta otomatik etkinleştir:",
"settings.experimental.remote.active": "Aktif",
"settings.experimental.remote.inactive": "Pasif",
"settings.experimental.remote.hint": "Geçiş yapmak için sohbette /remote kullanın",
"settings.experimental.toolToggles": "Araç Açma/Kapatma",
"settings.agentBehaviour.defaultAgent.title": "Varsayılan Ajan",
+8
View File
@@ -1075,6 +1075,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Продовжувати цикл агента, коли дозвіл відхилено",
"settings.experimental.mcpTimeout.title": "Тайм-аут MCP (мс)",
"settings.experimental.mcpTimeout.description": "Тайм-аут у мілісекундах для запитів до MCP-сервера",
"settings.experimental.remote.title": "Керування Remote",
"settings.experimental.remote.description":
"Увімкніть керування Remote сеансами через Kilo Cloud. Це також вплине на CLI на цьому комп'ютері.",
"settings.experimental.remote.current": "Поточний стан:",
"settings.experimental.remote.startup": "Автоматичне ввімкнення під час запуску:",
"settings.experimental.remote.active": "Активний",
"settings.experimental.remote.inactive": "Неактивний",
"settings.experimental.remote.hint": "Використовуйте /remote у чаті для перемикання",
"settings.experimental.toolToggles": "Перемикачі інструментів",
"settings.agentBehaviour.defaultAgent.title": "Агент за замовчуванням",
+7
View File
@@ -1048,6 +1048,13 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "权限被拒绝时继续智能体循环",
"settings.experimental.mcpTimeout.title": "MCP 超时(毫秒)",
"settings.experimental.mcpTimeout.description": "MCP 服务器请求的超时时间(毫秒)",
"settings.experimental.remote.title": "Remote 控制",
"settings.experimental.remote.description": "通过 Kilo Cloud 启用会话的 Remote 控制。这也会影响此计算机上的 CLI。",
"settings.experimental.remote.current": "当前状态:",
"settings.experimental.remote.startup": "启动时自动启用:",
"settings.experimental.remote.active": "已启用",
"settings.experimental.remote.inactive": "未启用",
"settings.experimental.remote.hint": "在聊天中使用 /remote 进行切换",
"settings.experimental.toolToggles": "工具开关",
"settings.agentBehaviour.defaultAgent.title": "默认智能体",
"settings.agentBehaviour.defaultAgent.description": "未指定时使用的智能体",
+7
View File
@@ -1050,6 +1050,13 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "權限被拒絕時繼續 Agent 迴圈",
"settings.experimental.mcpTimeout.title": "MCP 逾時(毫秒)",
"settings.experimental.mcpTimeout.description": "MCP 伺服器請求的逾時時間(毫秒)",
"settings.experimental.remote.title": "Remote 控制",
"settings.experimental.remote.description": "透過 Kilo Cloud 啟用工作階段的 Remote 控制。這也會影響此電腦上的 CLI。",
"settings.experimental.remote.current": "目前狀態:",
"settings.experimental.remote.startup": "啟動時自動啟用:",
"settings.experimental.remote.active": "已啟用",
"settings.experimental.remote.inactive": "已停用",
"settings.experimental.remote.hint": "在聊天中使用 /remote 來切換",
"settings.experimental.toolToggles": "工具開關",
"settings.agentBehaviour.defaultAgent.title": "預設 Agent",
"settings.agentBehaviour.defaultAgent.description": "未指定時使用的 Agent",
@@ -1072,6 +1072,66 @@
}
}
/* Remote Settings */
[data-component="remote-settings"] {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-weak-base);
}
[data-slot="remote-settings-header"] {
margin-bottom: 4px;
[data-slot="settings-row-label-title"] {
font-weight: 500;
margin-bottom: 4px;
}
[data-slot="settings-row-label-subtitle"] {
font-size: 12px;
color: var(--text-weak-base, var(--vscode-descriptionForeground));
}
}
[data-slot="remote-settings-row"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
[data-slot="remote-settings-label"] {
font-size: 12px;
color: var(--text-weak);
white-space: nowrap;
}
[data-slot="remote-settings-block"] {
display: flex;
flex-direction: column;
gap: 2px;
}
[data-slot="remote-settings-status"] {
font-size: 12px;
font-weight: 500;
color: var(--text-weak);
}
[data-slot="remote-settings-status"][data-active="true"] {
color: var(--vscode-testing-iconPassed, #5cb85c);
}
[data-slot="remote-settings-hint"] {
font-size: 11px;
color: var(--text-weak);
opacity: 0.7;
font-style: italic;
}
.prompt-input-hint-actions {
display: flex;
gap: 4px;
@@ -421,6 +421,7 @@ export interface Config {
instructions?: string[]
skills?: SkillsConfig
snapshot?: boolean
remote_control?: boolean
share?: "manual" | "auto" | "disabled"
username?: string
watcher?: WatcherConfig
@@ -1503,6 +1504,7 @@ export type ExtensionMessage =
| McpStatusLoadedMessage
| ClearPendingPromptsMessage
| ExtensionDataReadyMessage
| RemoteStatusMessage
// ============================================
// Messages FROM webview TO extension
@@ -1911,6 +1913,18 @@ export interface CloseSessionRequest {
sessionId: string
}
/** Persist a non-worktree session to agent-manager.json (worktreeId = null). */
export interface PersistSessionRequest {
type: "agentManager.persistSession"
sessionId: string
}
/** Remove a non-worktree session from agent-manager.json. */
export interface ForgetSessionRequest {
type: "agentManager.forgetSession"
sessionId: string
}
// Rename a worktree's display label
export interface RenameWorktreeRequest {
type: "agentManager.renameWorktree"
@@ -2153,6 +2167,31 @@ export interface SetDefaultBaseBranchRequest {
branch?: string
}
// Report all open session IDs to extension for heartbeat (webview → extension)
export interface AgentManagerOpenSessionsMessage {
type: "agentManager.openSessions"
sessionIDs: string[]
}
export interface RemoteStatusMessage {
type: "remoteStatus"
enabled: boolean
connected: boolean
}
export interface ToggleRemoteMessage {
type: "toggleRemote"
}
export interface SetRemoteEnabledMessage {
type: "setRemoteEnabled"
enabled: boolean
}
export interface RequestRemoteStatusMessage {
type: "requestRemoteStatus"
}
export interface ConnectProviderMessage {
type: "connectProvider"
requestId: string
@@ -2350,6 +2389,8 @@ export type WebviewMessage =
| AddSessionToWorktreeRequest
| ForkSessionRequest
| CloseSessionRequest
| PersistSessionRequest
| ForgetSessionRequest
| RenameWorktreeRequest
| TelemetryRequest
| RequestRepoInfoMessage
@@ -2398,6 +2439,7 @@ export type WebviewMessage =
| OpenSubAgentViewerRequest
| PreviewImageRequest
| SetDefaultBaseBranchRequest
| AgentManagerOpenSessionsMessage
| FetchMarketplaceDataMessage
| FilterMarketplaceItemsMessage
| InstallMarketplaceItemMessage
@@ -2412,6 +2454,9 @@ export type WebviewMessage =
| RequestRecentsMessage
| ToggleFavoriteRequest
| RequestFavoritesMessage
| ToggleRemoteMessage
| SetRemoteEnabledMessage
| RequestRemoteStatusMessage
| ContinueInWorktreeRequest
| CreateSectionRequest
| RenameSectionRequest
+32
View File
@@ -478,6 +478,8 @@ export const RunCommand = cmd({
async function loop() {
const toggles = new Map<string, boolean>()
const MAX_RETRIES = 3 // kilocode_change
let retries = 0 // kilocode_change
for await (const event of events.stream) {
if (
@@ -568,6 +570,16 @@ export const RunCommand = cmd({
UI.error(err)
}
// kilocode_change start
if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
event.properties.status.type === "busy"
) {
retries = 0
}
// kilocode_change end
if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
@@ -599,8 +611,28 @@ export const RunCommand = cmd({
await sdk.permission.reply({
requestID: permission.id,
reply: "reject",
}) // kilocode_change
} // kilocode_change
// kilocode_change start - network retry handling
if (event.type === "session.network.asked") {
const request = event.properties
if (request.sessionID !== sessionID) continue
retries++
if (retries > MAX_RETRIES) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL + `network retry limit reached (${MAX_RETRIES}); rejecting`,
)
await sdk.network.reject({ requestID: request.id })
continue
}
const delay = Math.min(5000 * Math.pow(2, retries - 1), 60000)
await new Promise((r) => setTimeout(r, delay))
await sdk.network.reply({
requestID: request.id,
})
}
// kilocode_change end
}
}
+1 -1
View File
@@ -275,7 +275,7 @@ function App() {
// 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({ sessionID }).catch(() => {})
sdk.client.session.viewed({ focused: sessionID ? [sessionID] : [] }).catch(() => {})
})
// kilocode_change end
@@ -9,6 +9,7 @@ import type {
Command,
PermissionRequest,
QuestionRequest,
SessionNetworkWait, // kilocode_change
LspStatus,
McpStatus,
McpResource,
@@ -48,6 +49,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
question: {
[sessionID: string]: QuestionRequest[]
}
// kilocode_change start
network: {
[sessionID: string]: SessionNetworkWait[]
}
// kilocode_change end
config: Config
session: Session[]
session_status: {
@@ -88,6 +94,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: [],
permission: {},
question: {},
// kilocode_change start
network: {},
// kilocode_change end
command: [],
provider: [],
provider_default: {},
@@ -131,6 +140,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
delete draft.session_diff[sessionID]
delete draft.session_status[sessionID]
delete draft.todo[sessionID]
delete draft.network[sessionID]
}),
)
fullSyncedSessions.delete(sessionID)
@@ -224,8 +234,58 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
)
break
} // kilocode_change
// kilocode_change start
case "session.network.replied":
case "session.network.rejected": {
const requests = store.network[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"network",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "session.network.restored": {
const requests = store.network[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (match.found) {
setStore("network", event.properties.sessionID, match.index, "restored", true)
}
break
}
case "session.network.asked": {
const request = event.properties
const requests = store.network[request.sessionID]
if (!requests) {
setStore("network", request.sessionID, [request])
break
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("network", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"network",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
// kilocode_change end
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
@@ -467,7 +527,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), // kilocode_change
// kilocode_change start
sdk.client.network.list().then((x) => {
const next: Record<string, SessionNetworkWait[]> = {}
for (const item of x.data ?? []) {
if (!next[item.sessionID]) next[item.sessionID] = []
next[item.sessionID].push(item)
}
setStore("network", reconcile(next))
}),
// kilocode_change end
sdk.client.session.status().then((x) => {
setStore("session_status", reconcile(x.data!))
}),
@@ -78,6 +78,7 @@ import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { NetworkPrompt } from "./network" // kilocode_change
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
@@ -141,6 +142,12 @@ export function Session() {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
// kilocode_change start
const network = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.network[x.id] ?? [])
})
// kilocode_change end
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -181,6 +188,15 @@ export function Session() {
},
),
)
createEffect(
on(
() => [route.sessionID, network().length] as const,
([id, len], prev) => {
if (!prev || prev[0] !== id) return
if (len > prev[1] && bellEnabled()) bell()
},
),
)
// kilocode_change end
const dimensions = useTerminalDimensions()
@@ -1203,8 +1219,19 @@ export function Session() {
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
{/* kilocode_change start */}
<Show when={permissions().length === 0 && questions().length === 0 && network().length > 0}>
<NetworkPrompt request={network()[0]} />
</Show>
{/* kilocode_change end */}
{/* kilocode_change start */}
<Prompt
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
visible={
!session()?.parentID &&
permissions().length === 0 &&
questions().length === 0 &&
network().length === 0
} // kilocode_change end
ref={(r) => {
prompt = r
promptRef.set(r)
@@ -1213,7 +1240,7 @@ export function Session() {
r.set(route.initialPrompt)
}
}}
disabled={permissions().length > 0 || questions().length > 0}
disabled={permissions().length > 0 || questions().length > 0 || network().length > 0} // kilocode_change
onSubmit={() => {
toBottom()
}}
@@ -0,0 +1,66 @@
// kilocode_change - new file
/** @jsxImportSource @opentui/solid */
import { Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../component/border"
import { useSDK } from "../../context/sdk"
import { useDialog } from "../../ui/dialog"
import type { SessionNetworkWait } from "@kilocode/sdk/v2"
import { useKeybind } from "../../context/keybind"
export function NetworkPrompt(props: { request: SessionNetworkWait }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const dialog = useDialog()
function reply() {
void sdk.client.network.reply({ requestID: props.request.id }).catch(() => {})
}
function reject() {
void sdk.client.network.reject({ requestID: props.request.id }).catch(() => {})
}
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "return" && props.request.restored) {
evt.preventDefault()
reply()
return
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
>
<box flexDirection="column" gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<Show
when={props.request.restored}
fallback={
<>
<text fg={theme.warning}>Network disconnected</text>
<text fg={theme.text}>{props.request.message}</text>
<text fg={theme.textMuted}>Waiting for network...</text>
<text fg={theme.textMuted}>Press Esc to stop this turn.</text>
</>
}
>
<text fg={theme.success}>Network reconnected</text>
<text fg={theme.text}>Connection restored.</text>
<text fg={theme.textMuted}>Press Enter to resume this turn.</text>
<text fg={theme.textMuted}>Press Esc to stop.</text>
</Show>
</box>
</box>
)
}

Some files were not shown because too many files have changed in this diff Show More