mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-17 17:43:04 +08:00
1828 lines
66 KiB
TypeScript
1828 lines
66 KiB
TypeScript
import * as fs from "fs"
|
|
import * as path from "path"
|
|
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
|
|
import type { KiloConnectionService } from "../services/cli-backend"
|
|
import { getErrorMessage } from "../kilo-provider-utils"
|
|
import { resolveLocalDiffTarget } from "../diff/shared/target"
|
|
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
|
|
import { isAbsolutePath } from "../path-utils"
|
|
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
|
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
|
|
import { handleSection } from "./section-handler"
|
|
import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch"
|
|
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
|
|
import { PRStatusBridge } from "./pr-status-bridge"
|
|
import { GitOps } from "./GitOps"
|
|
import { versionedName } from "./branch-name"
|
|
import { classifyWorktreeError } from "./git-import"
|
|
import { SetupScriptService } from "./SetupScriptService"
|
|
import { SetupScriptRunner } from "./SetupScriptRunner"
|
|
import { copyEnvFiles } from "./env-copy"
|
|
import { SessionTerminalManager } from "./SessionTerminalManager"
|
|
import { createTerminalHost } from "./terminal-host"
|
|
import { TerminalRouter } from "./terminal-routing"
|
|
import { executeVscodeTask } from "./task-runner"
|
|
import { startVscodeRunTask } from "./run/task"
|
|
import { RunController } from "./run/controller"
|
|
import { handleRunMessage } from "./run/message"
|
|
import { forkSession } from "./fork-session"
|
|
import { continueInWorktree } from "./continue-in-worktree"
|
|
import { WorktreeDiffController } from "./worktree-diff-controller"
|
|
import { WorktreeImporter } from "./worktree-importer"
|
|
import { recordPromotionHandoff } from "./promotion-handoff"
|
|
import { restoreWorktrees } from "./state-recovery"
|
|
import { createLocalDiff, diffSummary as localDiffSummary } from "./local-diff"
|
|
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
|
|
import { stopSessionProcesses } from "../kilo-provider/background-process"
|
|
|
|
import { startSession } from "./mcp-warmup"
|
|
import { readTerminalFont, watchTerminalFont } from "./terminal-font"
|
|
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 type { Host, PanelContext, OutputHandle, Disposable } from "./host"
|
|
|
|
/**
|
|
* AgentManagerProvider opens the Agent Manager panel.
|
|
*
|
|
* Uses WorktreeStateManager for centralized state persistence. Worktrees and
|
|
* sessions are stored in `.kilo/agent-manager.json`. The UI shows two
|
|
* sections: WORKTREES (top) with managed worktrees + their sessions, and
|
|
* SESSIONS (bottom) with unassociated local sessions.
|
|
*/
|
|
export class AgentManagerProvider implements Disposable {
|
|
public static readonly viewType = "kilo-code.new.AgentManagerPanel"
|
|
|
|
private panel: PanelContext | undefined
|
|
private outputChannel: OutputHandle
|
|
private worktrees: WorktreeManager | undefined
|
|
private state: WorktreeStateManager | undefined
|
|
private setupScript: SetupScriptService | undefined
|
|
private importer: WorktreeImporter
|
|
private terminalManager: SessionTerminalManager
|
|
private terminalRouter: TerminalRouter
|
|
private run: RunController
|
|
private stateReady: Promise<void> | undefined
|
|
private statsPoller: GitStatsPoller
|
|
private prBridge!: PRStatusBridge
|
|
private gitOps: GitOps
|
|
private diffs: WorktreeDiffController
|
|
private staleWorktreeIds = new Set<string>()
|
|
private toolRequests = new Set<string>()
|
|
private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined
|
|
private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined
|
|
private unsubTool: (() => void) | undefined
|
|
private unsubFont: (() => void) | undefined
|
|
private closing: Promise<void> | undefined
|
|
private onVisibilityChange: ((visible: boolean) => void) | undefined
|
|
|
|
/** Session ID most recently loaded via a `loadMessages` message from the webview.
|
|
* Updated synchronously — unlike the session provider's currentSession which depends on
|
|
* an async `session.get` round-trip and can be stale during rapid tab switches. */
|
|
private activeSessionId: string | undefined
|
|
constructor(
|
|
private readonly host: Host,
|
|
private readonly connectionService: KiloConnectionService,
|
|
) {
|
|
this.outputChannel = host.createOutput("Kilo Agent Manager")
|
|
this.terminalManager = new SessionTerminalManager(
|
|
(msg) => this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
|
|
createTerminalHost(),
|
|
)
|
|
this.terminalRouter = new TerminalRouter({
|
|
getClient: () => this.connectionService.getClient(),
|
|
getServerConfig: () => this.connectionService.getServerConfig() ?? undefined,
|
|
getRoot: () => this.getRoot(),
|
|
getWorktreePath: (id) => this.getStateManager()?.getWorktree(id)?.path,
|
|
log: (...args) => this.log("[XTerm]", ...args),
|
|
post: (msg) => this.postToWebview(msg),
|
|
getTerminalFont: () => readTerminalFont(),
|
|
})
|
|
this.unsubFont = watchTerminalFont((font) => {
|
|
this.postToWebview({ type: "agentManager.terminal.fontChanged", font })
|
|
})
|
|
this.run = new RunController({
|
|
root: () => this.getRoot(),
|
|
state: () => this.getStateManager(),
|
|
open: (file) => this.host.openDocument(file),
|
|
start: startVscodeRunTask,
|
|
post: (status) => this.postToWebview({ type: "agentManager.runStatus", ...status }),
|
|
error: (message) => this.postToWebview({ type: "error", message }),
|
|
log: (msg) => this.outputChannel.appendLine(`[RunScript] ${msg}`),
|
|
refresh: () => this.pushState(),
|
|
})
|
|
this.importer = new WorktreeImporter({
|
|
manager: () => this.getWorktreeManager(),
|
|
state: () => this.getStateManager(),
|
|
post: (msg) => this.postToWebview(msg),
|
|
push: () => this.pushState(),
|
|
setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id),
|
|
session: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id),
|
|
register: (sid, dir) => this.registerWorktreeSession(sid, dir),
|
|
ready: (sid, result, id) => this.notifyWorktreeReady(sid, result, id),
|
|
log: (...args) => this.log(...args),
|
|
})
|
|
const semaphore = new Semaphore(3)
|
|
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore })
|
|
const local = createLocalDiff(this.gitOps, (...args) => this.log(...args))
|
|
this.diffs = new WorktreeDiffController({
|
|
getState: () => this.getStateManager(),
|
|
getRoot: () => this.getRoot(),
|
|
getStateReady: () => this.stateReady,
|
|
git: this.gitOps,
|
|
localDiff: local.summary,
|
|
localDiffFile: local.file,
|
|
post: (msg) => this.postToWebview(msg),
|
|
log: (...args) => this.log(...args),
|
|
})
|
|
this.statsPoller = new GitStatsPoller({
|
|
getWorktrees: () => this.state?.getWorktrees() ?? [],
|
|
getWorkspaceRoot: () => this.getRoot(),
|
|
localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)),
|
|
semaphore,
|
|
onStats: (stats) => {
|
|
const msg = { type: "agentManager.worktreeStats" as const, stats }
|
|
this.cachedWorktreeStats = msg
|
|
this.postToWebview(msg)
|
|
},
|
|
onLocalStats: (stats) => {
|
|
const msg = { type: "agentManager.localStats" as const, stats }
|
|
this.cachedLocalStats = msg
|
|
this.postToWebview(msg)
|
|
},
|
|
onWorktreePresence: (presence) => {
|
|
this.onWorktreePresence(presence)
|
|
},
|
|
log: (...args) => this.log(...args),
|
|
git: this.gitOps,
|
|
})
|
|
this.prBridge = PRStatusBridge.create({
|
|
getWorktrees: () => this.state?.getWorktrees() ?? [],
|
|
getWorkspaceRoot: () => this.getRoot(),
|
|
postToWebview: (m) => this.postToWebview(m),
|
|
updateWorktreePR: (id, n, u, s) => this.state?.updateWorktreePR(id, n, u, s),
|
|
hasPersistedPR: (id: string) => !!this.state?.getWorktree(id)?.prNumber,
|
|
openExternal: (u) => this.host.openExternal(u),
|
|
log: (...a) => this.log(...a),
|
|
semaphore,
|
|
})
|
|
this.unsubTool = this.connectionService.onEventFiltered(
|
|
(event) => (event as { type?: string }).type === "kilocode.agent_manager.start",
|
|
(event, directory) => this.onToolEvent(event, directory),
|
|
)
|
|
}
|
|
|
|
private log(...args: unknown[]) {
|
|
const msg = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")
|
|
this.outputChannel.appendLine(`${new Date().toISOString()} ${msg}`)
|
|
}
|
|
|
|
public openPanel(preserveFocus?: boolean): void {
|
|
if (this.panel) {
|
|
this.log("Panel already open, revealing")
|
|
this.panel.reveal(preserveFocus)
|
|
if (!preserveFocus) this.postToWebview({ type: "action", action: "focusInput" })
|
|
return
|
|
}
|
|
this.log("Opening Agent Manager panel")
|
|
this.host.capture("Agent Manager Opened", { source: PLATFORM })
|
|
|
|
this.attachPanel(
|
|
this.host.openPanel({
|
|
onBeforeMessage: (msg) => this.onMessage(msg),
|
|
worktreeDirectories: () => this.getWorktreeDirectories(),
|
|
}),
|
|
)
|
|
}
|
|
|
|
public onPanelVisibilityChange(cb: (visible: boolean) => void): void {
|
|
this.onVisibilityChange = cb
|
|
}
|
|
|
|
/** Restore the Agent Manager panel from a previously serialized state.
|
|
* The caller (extension.ts / vscode-host.ts) wraps the raw panel before passing it. */
|
|
public deserializePanel(ctx: PanelContext): void {
|
|
if (this.panel) {
|
|
this.log("Panel already exists during deserialization, disposing duplicate")
|
|
ctx.dispose()
|
|
return
|
|
}
|
|
this.log("Deserializing Agent Manager panel")
|
|
this.attachPanel(ctx)
|
|
}
|
|
|
|
/** Message interceptor — exposed for the deserialization path in extension.ts. */
|
|
public handleMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
|
return this.onMessage(msg)
|
|
}
|
|
|
|
/** Wire up a panel context (shared by openPanel and deserializePanel). */
|
|
private attachPanel(ctx: PanelContext): void {
|
|
if (this.panel) {
|
|
this.log("Disposing previous panel before attaching new one")
|
|
this.panel.dispose()
|
|
this.panel = undefined
|
|
}
|
|
this.panel = ctx
|
|
|
|
this.statsPoller.setVisible(ctx.visible)
|
|
this.onVisibilityChange?.(ctx.visible)
|
|
ctx.onDidChangeVisibility((visible) => {
|
|
this.statsPoller.setVisible(visible)
|
|
})
|
|
|
|
ctx.sessions.onFollowupAdopted((session, directory) => {
|
|
this.adoptFollowupInWorktree(session, directory)
|
|
})
|
|
|
|
this.stateReady = this.initializeState()
|
|
void this.sendRepoInfo()
|
|
this.sendKeybindings()
|
|
this.prBridge.attachPanel(ctx)
|
|
ctx.onDidDispose(() => {
|
|
// Only clear if this is still the active panel — a newer panel may
|
|
// have already replaced us via attachPanel.
|
|
if (this.panel === ctx) {
|
|
this.log("Panel disposed")
|
|
this.statsPoller.stop()
|
|
this.prBridge.poller.stop()
|
|
this.diffs.stop()
|
|
this.panel = undefined
|
|
this.onVisibilityChange?.(false)
|
|
}
|
|
ctx.sessions.dispose()
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State initialization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private async initializeState(): Promise<void> {
|
|
const manager = this.getWorktreeManager()
|
|
const state = this.getStateManager()
|
|
if (!manager || !state) {
|
|
this.pushEmptyState()
|
|
return
|
|
}
|
|
|
|
await this.ensureGitExclude(manager)
|
|
const loaded = await state.load()
|
|
manager.cleanupOrphanedTempDirs()
|
|
|
|
if (loaded.status === "failed" && !(await state.prepareRecovery())) {
|
|
this.postToWebview({ type: "error", message: "Agent Manager state could not be recovered." })
|
|
this.pushState()
|
|
return
|
|
}
|
|
|
|
await this.recoverWorktrees(manager, state)
|
|
|
|
// When the .kilocode → .kilo migration rewrote git worktree refs, nudge
|
|
// VS Code's git extension to re-discover them. Without this, worktrees
|
|
// won't appear in Source Control until the next VS Code restart.
|
|
if (loaded.refsFixed > 0) {
|
|
this.log(`Migration fixed ${loaded.refsFixed} git worktree ref(s), refreshing git`)
|
|
this.host.refreshGit()
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
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()
|
|
}
|
|
|
|
private async ensureGitExclude(manager: WorktreeManager): Promise<void> {
|
|
await manager.ensureGitExclude().catch((err) => {
|
|
this.log("Failed to update git exclude:", err)
|
|
})
|
|
}
|
|
|
|
private async recoverWorktrees(manager: WorktreeManager, state: WorktreeStateManager): Promise<void> {
|
|
const infos = await manager.discoverWorktrees().catch((err) => {
|
|
this.log("Failed to discover worktrees during state recovery:", err)
|
|
return []
|
|
})
|
|
if (infos.length === 0) return
|
|
|
|
const result = restoreWorktrees(state, infos)
|
|
if (result.worktrees === 0 && result.sessions === 0) return
|
|
|
|
this.log(`Recovered ${result.worktrees} worktree(s) and ${result.sessions} session(s) from disk`)
|
|
await state.flush()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Message interceptor
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
|
if (this.prBridge.handleMessage(msg)) return null
|
|
if (msg.type === "requestFileSearch" && typeof msg.sessionID !== "string" && this.activeSessionId) {
|
|
return { ...msg, sessionID: this.activeSessionId }
|
|
}
|
|
msg = await this.contextMessage(msg)
|
|
const m = msg as unknown as AgentManagerInMessage
|
|
if (this.shouldWaitForState(m)) await this.waitForStateReady(m.type)
|
|
|
|
const worktree = await this.onWorktreeMessage(m)
|
|
if (worktree !== undefined) return worktree
|
|
const session = this.onSessionMessage(m, msg)
|
|
if (session !== undefined) return session
|
|
const ui = this.onUiMessage(m, msg)
|
|
if (ui !== undefined) return ui
|
|
const state = this.onStateMessage(m)
|
|
if (state !== undefined) return state
|
|
const imports = this.onImportMessage(m)
|
|
if (imports !== undefined) return imports
|
|
const diff = this.onDiffMessage(m)
|
|
if (diff !== undefined) return diff
|
|
const bridge = this.onBridgeMessage(m)
|
|
if (bridge !== undefined) return bridge
|
|
if (this.terminalRouter.handle(m)) return null
|
|
|
|
return msg
|
|
}
|
|
|
|
private async contextMessage(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
if (msg.type !== "requestGitChangesContext") return msg
|
|
const ctx = typeof msg.agentManagerContext === "string" ? msg.agentManagerContext : undefined
|
|
const target = ctx ? await this.contextTarget(ctx) : undefined
|
|
const sid = typeof msg.sessionID === "string" ? msg.sessionID : this.activeSessionId
|
|
const next = sid && typeof msg.sessionID !== "string" ? { ...msg, sessionID: sid } : msg
|
|
if (target) return { ...next, ...target }
|
|
if (!sid) return next
|
|
|
|
const state = this.getStateManager()
|
|
const session = state?.getSession(sid)
|
|
const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined
|
|
if (!worktree) return next
|
|
return { ...next, contextDirectory: worktree.path, gitChangesBase: remoteRef(worktree) }
|
|
}
|
|
|
|
private async contextTarget(ctx: string): Promise<Record<string, unknown> | undefined> {
|
|
if (ctx === "local") {
|
|
const root = this.getRoot()
|
|
if (!root) return undefined
|
|
const target = await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), root)
|
|
if (!target) return { contextDirectory: root }
|
|
return { contextDirectory: target.directory, gitChangesBase: target.baseBranch }
|
|
}
|
|
|
|
const worktree = this.getStateManager()?.getWorktree(ctx)
|
|
if (!worktree) return undefined
|
|
return { contextDirectory: worktree.path, gitChangesBase: remoteRef(worktree) }
|
|
}
|
|
|
|
private async onWorktreeMessage(m: AgentManagerInMessage): Promise<Record<string, unknown> | null | undefined> {
|
|
if (m.type === "agentManager.createWorktree") return this.onCreateWorktree(m.baseBranch, m.branchName)
|
|
if (m.type === "agentManager.deleteWorktree") return this.onDeleteWorktree(m.worktreeId)
|
|
if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m.worktreeId)
|
|
if (m.type === "agentManager.promoteSession") return this.onPromoteSession(m.sessionId)
|
|
if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId, m.sessionId)
|
|
if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId, m.messageId)
|
|
if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId)
|
|
}
|
|
|
|
private onSessionMessage(
|
|
m: AgentManagerInMessage,
|
|
msg: Record<string, unknown>,
|
|
): Record<string, unknown> | null | undefined {
|
|
if (m.type === "agentManager.openLocally") {
|
|
this.panel?.sessions.clearSessionDirectory(m.sessionId)
|
|
const state = this.getStateManager()
|
|
if (state?.getSession(m.sessionId)) {
|
|
state.moveSession(m.sessionId, null)
|
|
this.pushState()
|
|
}
|
|
return null
|
|
}
|
|
|
|
if (m.type === "continueInWorktree") {
|
|
void this.continueFromSidebar(m.sessionId, (status, detail, error) => {
|
|
this.panel?.postMessage({ type: "continueInWorktreeProgress", status, detail, error })
|
|
})
|
|
return null
|
|
}
|
|
|
|
if (m.type === "agentManager.persistSession" || m.type === "agentManager.forgetSession") {
|
|
const persist = m.type === "agentManager.persistSession"
|
|
void this.stateReady?.then(() => {
|
|
const state = this.getStateManager()
|
|
if (!state) return
|
|
if (persist) {
|
|
if (!state.getSession(m.sessionId)) state.addSession(m.sessionId, null)
|
|
return
|
|
}
|
|
state.removeSession(m.sessionId)
|
|
})
|
|
return null
|
|
}
|
|
|
|
if ((m.type === "sendMessage" || m.type === "sendCommand") && !m.sessionID) {
|
|
const ctx = typeof m.agentManagerContext === "string" ? m.agentManagerContext : undefined
|
|
const worktree = ctx && ctx !== "local" ? this.getStateManager()?.getWorktree(ctx) : undefined
|
|
if (worktree) {
|
|
if (m.draftID) this.activeSessionId = m.draftID
|
|
return { ...msg, contextDirectory: worktree.path }
|
|
}
|
|
}
|
|
|
|
if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) {
|
|
this.activeSessionId = m.draftID
|
|
return msg
|
|
}
|
|
|
|
if (m.type === "requestTerminalContext") {
|
|
if (m.sessionID && !this.terminalManager.hasActiveTerminal()) this.terminalManager.showExisting(m.sessionID)
|
|
return msg
|
|
}
|
|
|
|
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)
|
|
return msg
|
|
}
|
|
|
|
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()) {
|
|
this.panel.sessions.trackSession(id)
|
|
}
|
|
})
|
|
return msg
|
|
}
|
|
|
|
if (m.type === "abort") {
|
|
this.host.capture("Agent Manager Session Stopped", {
|
|
source: PLATFORM,
|
|
sessionId: m.sessionID,
|
|
})
|
|
return msg
|
|
}
|
|
|
|
if (m.type === "agentManager.openSessions") {
|
|
this.connectionService.registerOpen("agent-manager", m.sessionIDs)
|
|
return null
|
|
}
|
|
}
|
|
|
|
private onUiMessage(
|
|
m: AgentManagerInMessage,
|
|
msg: Record<string, unknown>,
|
|
): Record<string, unknown> | null | undefined {
|
|
if (m.type === "agentManager.configureSetupScript") {
|
|
void this.configureSetupScript()
|
|
return null
|
|
}
|
|
if (handleRunMessage(this.run, m)) return null
|
|
if (m.type === "agentManager.showTerminal") {
|
|
this.terminalManager.showTerminal(m.sessionId, this.state)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.showLocalTerminal") {
|
|
this.terminalManager.showLocalTerminal()
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.openWorktree") {
|
|
this.openWorktreeDirectory(m.worktreeId)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.copyToClipboard") {
|
|
this.host.copyToClipboard(m.text)
|
|
return null
|
|
}
|
|
if (m.type === "previewImage") return msg
|
|
if (m.type === "saveImage") return msg
|
|
if (m.type === "agentManager.showExistingLocalTerminal") {
|
|
this.terminalManager.syncLocalOnSessionSwitch()
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.requestRepoInfo") {
|
|
void this.sendRepoInfo()
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.createMultiVersion") {
|
|
void this.onCreateMultiVersion(m)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.renameWorktree") {
|
|
const state = this.getStateManager()
|
|
if (state) {
|
|
state.updateWorktreeLabel(m.worktreeId, m.label)
|
|
this.pushState()
|
|
}
|
|
return null
|
|
}
|
|
}
|
|
|
|
private onStateMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
|
|
if (m.type === "agentManager.requestState") {
|
|
this.onRequestState()
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.setTabOrder") {
|
|
this.state?.setTabOrder(m.key, m.order)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.setWorktreeOrder") {
|
|
this.state?.setWorktreeOrder(m.order)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.setSessionsCollapsed") {
|
|
this.state?.setSessionsCollapsed(m.collapsed)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.setSidebarCollapsed") {
|
|
this.state?.setSidebarCollapsed(m.collapsed)
|
|
return null
|
|
}
|
|
if (this.handleSection(m)) return null
|
|
if (m.type === "agentManager.setReviewDiffStyle") {
|
|
this.state?.setReviewDiffStyle(m.style)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.setReviewMarkdownRender") {
|
|
void setDiffMarkdownRender(m.render).then(() => this.pushState())
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.setDefaultBaseBranch") {
|
|
this.state?.setDefaultBaseBranch(normalizeBaseBranch(m.branch))
|
|
this.pushState()
|
|
return null
|
|
}
|
|
}
|
|
|
|
private onImportMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
|
|
if (m.type === "agentManager.requestBranches") {
|
|
void this.importer.branches()
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.requestExternalWorktrees") {
|
|
void this.importer.external()
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.importFromBranch") {
|
|
void this.importer.branch(m.branch)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.importFromPR") {
|
|
void this.importer.pr(m.url)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.importExternalWorktree") {
|
|
void this.importer.path(m.path, m.branch)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.importAllExternalWorktrees") {
|
|
void this.importer.all()
|
|
return null
|
|
}
|
|
}
|
|
|
|
private onDiffMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
|
|
if (m.type === "agentManager.requestWorktreeDiff") {
|
|
void this.diffs.request(m.sessionId)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.requestWorktreeDiffFile") {
|
|
void this.diffs.requestFile(m.sessionId, m.file)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.applyWorktreeDiff") {
|
|
void this.diffs.apply(m.worktreeId, m.selectedFiles)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.revertWorktreeFile") {
|
|
void this.diffs.revert(m.sessionId, m.file)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.startDiffWatch") {
|
|
this.diffs.start(m.sessionId)
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.stopDiffWatch") {
|
|
this.diffs.stop()
|
|
return null
|
|
}
|
|
if (m.type === "agentManager.openFile") {
|
|
this.openWorktreeFile(m.sessionId, m.filePath, m.line, m.column)
|
|
return null
|
|
}
|
|
}
|
|
|
|
private onBridgeMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
|
|
if (m.type !== "openFile") return undefined
|
|
|
|
const sessionId = this.activeSessionId
|
|
const state = this.getStateManager()
|
|
if (sessionId && state?.directoryFor(sessionId)) {
|
|
this.openWorktreeFile(sessionId, m.filePath, m.line, m.column)
|
|
return null
|
|
}
|
|
}
|
|
|
|
private onRequestState(): void {
|
|
void this.stateReady
|
|
?.then(() => {
|
|
// When the folder is not a git repo (or has no folder open),
|
|
// this.state is never created. pushState() silently returns in that
|
|
// case, so re-send the empty/non-git state explicitly.
|
|
if (!this.state) {
|
|
this.pushEmptyState()
|
|
return
|
|
}
|
|
this.pushState()
|
|
// Re-send cached stats so the webview gets them even if the poller
|
|
// already emitted before the webview was ready to receive messages.
|
|
if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats)
|
|
if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats)
|
|
this.prBridge.replay()
|
|
// Refresh sessions after pushState so the webview's sessionsLoaded
|
|
// handler is guaranteed to be registered (requestState fires from
|
|
// onMount). Without this, the initial refreshSessions() in
|
|
// initializeState() can race ahead of webview mount, causing
|
|
// sessionsLoaded to never flip to true.
|
|
if (this.state.getSessions().length > 0) {
|
|
this.panel?.sessions.refreshSessions()
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
this.log("initializeState failed, pushing partial state:", err)
|
|
if (!this.state) {
|
|
this.pushEmptyState()
|
|
return
|
|
}
|
|
this.pushState()
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Resolve the effective base branch using the configured default, explicit override, and existence check. */
|
|
private async resolveBaseBranch(
|
|
manager: WorktreeManager,
|
|
state: WorktreeStateManager,
|
|
explicit?: string,
|
|
): Promise<string | undefined> {
|
|
const configured = state.getDefaultBaseBranch()
|
|
if (!configured && !explicit) return undefined
|
|
|
|
const configuredExists = configured ? await manager.branchExists(configured) : false
|
|
const result = chooseBaseBranch({ explicit, configured, configuredExists })
|
|
|
|
if (result.stale) {
|
|
this.clearStaleDefaultBaseBranch(state, result.stale)
|
|
}
|
|
return result.branch
|
|
}
|
|
|
|
/** Reset a stale default base branch and notify the webview. */
|
|
private clearStaleDefaultBaseBranch(state: WorktreeStateManager, stale: string): void {
|
|
this.log(`Default base branch "${stale}" no longer exists, clearing`)
|
|
state.setDefaultBaseBranch(undefined)
|
|
this.pushState()
|
|
}
|
|
|
|
/** Create a git worktree on disk and register it in state. Returns null on failure. */
|
|
private async createWorktreeOnDisk(opts?: {
|
|
groupId?: string
|
|
baseBranch?: string
|
|
branchName?: string
|
|
existingBranch?: string
|
|
name?: string
|
|
label?: string
|
|
}): Promise<{
|
|
worktree: ReturnType<WorktreeStateManager["addWorktree"]>
|
|
result: CreateWorktreeResult
|
|
} | null> {
|
|
const manager = this.getWorktreeManager()
|
|
const state = this.getStateManager()
|
|
if (!manager || !state) {
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "error",
|
|
message: "Open a folder that contains a git repository to use worktrees",
|
|
errorCode: "not_git_repo",
|
|
})
|
|
return null
|
|
}
|
|
|
|
this.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Creating git worktree..." })
|
|
|
|
// Resolve effective base branch using configured default
|
|
const effectiveBase = opts?.existingBranch
|
|
? undefined
|
|
: await this.resolveBaseBranch(manager, state, opts?.baseBranch)
|
|
|
|
let result: CreateWorktreeResult
|
|
try {
|
|
result = await manager.createWorktree({
|
|
prompt: opts?.name || "kilo",
|
|
baseBranch: effectiveBase ?? opts?.baseBranch,
|
|
branchName: opts?.branchName,
|
|
existingBranch: opts?.existingBranch,
|
|
})
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error)
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "error",
|
|
message: msg,
|
|
errorCode: classifyWorktreeError(msg),
|
|
})
|
|
this.host.capture("Agent Manager Session Error", {
|
|
source: PLATFORM,
|
|
error: msg,
|
|
context: "createWorktree",
|
|
})
|
|
return null
|
|
}
|
|
|
|
const worktree = state.addWorktree({
|
|
branch: result.branch,
|
|
path: result.path,
|
|
parentBranch: result.parentBranch,
|
|
remote: result.remote,
|
|
groupId: opts?.groupId,
|
|
label: opts?.label,
|
|
})
|
|
|
|
// Push state immediately so the sidebar shows the new worktree with a loading indicator
|
|
this.pushState()
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "creating",
|
|
message: "Setting up worktree...",
|
|
branch: result.branch,
|
|
worktreeId: worktree.id,
|
|
})
|
|
|
|
return { worktree, result }
|
|
}
|
|
|
|
/** Create a CLI session in a worktree directory. Returns null on failure. */
|
|
private async createSessionInWorktree(
|
|
worktreePath: string,
|
|
branch: string,
|
|
worktreeId?: string,
|
|
): Promise<Session | null> {
|
|
let client: KiloClient
|
|
try {
|
|
client = this.connectionService.getClient()
|
|
} catch (err) {
|
|
this.log("createSessionInWorktree: client not available:", err)
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "error",
|
|
message: "Not connected to CLI backend",
|
|
worktreeId,
|
|
})
|
|
this.host.capture("Agent Manager Session Error", {
|
|
source: PLATFORM,
|
|
error: "Not connected to CLI backend",
|
|
context: "createSession",
|
|
})
|
|
return null
|
|
}
|
|
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "starting",
|
|
message: "Starting session...",
|
|
branch,
|
|
worktreeId,
|
|
})
|
|
|
|
try {
|
|
const { data: session } = await startSession(
|
|
client,
|
|
worktreePath,
|
|
() => client.session.create({ directory: worktreePath, platform: PLATFORM }, { throwOnError: true }),
|
|
(...args) => this.log(...args),
|
|
)
|
|
return session
|
|
} catch (error) {
|
|
const err = getErrorMessage(error)
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "error",
|
|
message: `Failed to create session: ${err}`,
|
|
worktreeId,
|
|
})
|
|
this.host.capture("Agent Manager Session Error", {
|
|
source: PLATFORM,
|
|
error: err,
|
|
context: "createSession",
|
|
})
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Send worktreeSetup.ready + sessionMeta + pushState after worktree creation. */
|
|
private notifyWorktreeReady(sessionId: string, result: CreateWorktreeResult, worktreeId?: string): void {
|
|
this.pushState()
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "ready",
|
|
message: "Worktree ready",
|
|
sessionId,
|
|
branch: result.branch,
|
|
worktreeId,
|
|
})
|
|
this.postToWebview({
|
|
type: "agentManager.sessionMeta",
|
|
sessionId,
|
|
mode: "worktree",
|
|
branch: result.branch,
|
|
path: result.path,
|
|
parentBranch: result.parentBranch,
|
|
})
|
|
}
|
|
|
|
private async waitForStateReady(context: string): Promise<void> {
|
|
if (!this.stateReady) return
|
|
await this.stateReady.catch((err) => this.log(`${context}: stateReady rejected, continuing:`, err))
|
|
}
|
|
|
|
private shouldWaitForState(m: AgentManagerInMessage): boolean {
|
|
switch (m.type) {
|
|
case "agentManager.deleteWorktree":
|
|
case "agentManager.removeStaleWorktree":
|
|
case "agentManager.openLocally":
|
|
case "agentManager.addSessionToWorktree":
|
|
case "agentManager.closeSession":
|
|
case "agentManager.persistSession":
|
|
case "agentManager.forgetSession":
|
|
case "agentManager.renameWorktree":
|
|
case "agentManager.requestBranches":
|
|
case "agentManager.importFromBranch":
|
|
case "agentManager.importFromPR":
|
|
case "agentManager.importExternalWorktree":
|
|
case "agentManager.importAllExternalWorktrees":
|
|
case "agentManager.setTabOrder":
|
|
case "agentManager.setWorktreeOrder":
|
|
case "agentManager.setSessionsCollapsed":
|
|
case "agentManager.setSidebarCollapsed":
|
|
case "agentManager.setReviewDiffStyle":
|
|
case "agentManager.setDefaultBaseBranch":
|
|
case "agentManager.createSection":
|
|
case "agentManager.renameSection":
|
|
case "agentManager.deleteSection":
|
|
case "agentManager.setSectionColor":
|
|
case "agentManager.toggleSectionCollapsed":
|
|
case "agentManager.moveToSection":
|
|
case "agentManager.moveSection":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private onToolEvent(event: unknown, directory?: string): void {
|
|
const properties = (event as { properties?: unknown }).properties
|
|
const req = parseToolRequest(properties)
|
|
if (!req) return
|
|
if (directory) req.directory = directory
|
|
void this.startToolRequest(req)
|
|
}
|
|
|
|
private async startToolRequest(req: ToolRequest): Promise<void> {
|
|
await startFromTool(
|
|
{
|
|
getClient: () => this.connectionService.getClient(),
|
|
getRoot: () => this.getRoot(),
|
|
getState: () => this.getStateManager(),
|
|
getPanel: () => this.panel,
|
|
openPanel: (preserveFocus) => this.openPanel(preserveFocus),
|
|
waitReady: (context) => this.waitForStateReady(context),
|
|
createWorktree: (opts) => this.createWorktreeOnDisk(opts),
|
|
claimRequest: (id) => {
|
|
if (this.toolRequests.has(id)) return false
|
|
const oldest = this.toolRequests.size >= 100 ? this.toolRequests.values().next().value : undefined
|
|
if (oldest) this.toolRequests.delete(oldest)
|
|
this.toolRequests.add(id)
|
|
return true
|
|
},
|
|
cleanupWorktree: async (wid, dir) => {
|
|
this.getStateManager()?.removeWorktree(wid)
|
|
await this.getWorktreeManager()?.removeWorktree(dir)
|
|
this.pushState()
|
|
},
|
|
setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id),
|
|
createSessionInWorktree: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id),
|
|
registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir),
|
|
notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid),
|
|
push: () => this.pushState(),
|
|
post: (msg) => this.postToWebview(msg as AgentManagerOutMessage),
|
|
capture: (event, props) => this.host.capture(event, props),
|
|
log: (...args) => this.log(...args),
|
|
error: (msg) => this.host.showError(msg),
|
|
},
|
|
req,
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Worktree actions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Create a new worktree with an auto-created first session. */
|
|
private async onCreateWorktree(baseBranch?: string, branchName?: string): Promise<null> {
|
|
await this.waitForStateReady("onCreateWorktree")
|
|
|
|
const created = await this.createWorktreeOnDisk({ baseBranch, branchName })
|
|
if (!created) return null
|
|
|
|
// Run setup script for new worktree (blocks until complete, shows in overlay)
|
|
await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id)
|
|
|
|
const session = await this.createSessionInWorktree(created.result.path, created.result.branch, created.worktree.id)
|
|
if (!session) {
|
|
const state = this.getStateManager()
|
|
const manager = this.getWorktreeManager()
|
|
state?.removeWorktree(created.worktree.id)
|
|
await manager?.removeWorktree(created.result.path)
|
|
this.pushState()
|
|
return null
|
|
}
|
|
|
|
const state = this.getStateManager()!
|
|
state.addSession(session.id, created.worktree.id)
|
|
this.registerWorktreeSession(session.id, created.result.path)
|
|
// Push state before registerSession so the webview's sessionCreated handler
|
|
// sees the worktree mapping and routes the session to the worktree tab.
|
|
this.notifyWorktreeReady(session.id, created.result, created.worktree.id)
|
|
this.panel?.sessions.registerSession(session)
|
|
this.host.capture("Agent Manager Session Started", {
|
|
source: PLATFORM,
|
|
sessionId: session.id,
|
|
worktreeId: created.worktree.id,
|
|
branch: created.result.branch,
|
|
})
|
|
this.log(`Created worktree ${created.worktree.id} with session ${session.id}`)
|
|
return null
|
|
}
|
|
|
|
/** Delete a worktree and dissociate its sessions. */
|
|
private async onDeleteWorktree(worktreeId: string): Promise<null> {
|
|
const manager = this.getWorktreeManager()
|
|
const state = this.getStateManager()
|
|
if (!manager || !state) return null
|
|
const worktree = state.getWorktree(worktreeId)
|
|
if (!worktree) {
|
|
this.log(`Worktree ${worktreeId} not found in state`)
|
|
return null
|
|
}
|
|
// Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree.
|
|
// Pre-emptive skip covers any in-flight poll that already captured getWorktrees().
|
|
this.statsPoller.skipWorktree(worktreeId)
|
|
this.prBridge.remove(worktreeId)
|
|
this.run.remove(worktreeId)
|
|
const orphaned = state.removeWorktree(worktreeId)
|
|
if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) {
|
|
this.diffs.stop()
|
|
}
|
|
for (const s of orphaned) this.panel?.sessions.clearSessionDirectory(s.id)
|
|
this.pushState()
|
|
// Disk removal after state is clean — pollers no longer reference this worktree.
|
|
try {
|
|
await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch)
|
|
} catch (error) {
|
|
this.log(`Failed to remove worktree from disk: ${error}`)
|
|
}
|
|
this.log(`Deleted worktree ${worktreeId} (${worktree.originalBranch ?? worktree.branch})`)
|
|
return null
|
|
}
|
|
|
|
/** Remove a stale worktree entry from state without touching the filesystem. */
|
|
private async onRemoveStaleWorktree(worktreeId: string): Promise<null> {
|
|
const state = this.getStateManager()
|
|
if (!state) return null
|
|
if (!this.staleWorktreeIds.has(worktreeId)) {
|
|
this.log(`Ignored stale removal for non-stale worktree ${worktreeId}`)
|
|
return null
|
|
}
|
|
|
|
const worktree = state.getWorktree(worktreeId)
|
|
if (!worktree) {
|
|
this.clearStaleTracking(worktreeId)
|
|
this.pushState()
|
|
return null
|
|
}
|
|
|
|
const orphaned = state.removeWorktree(worktreeId)
|
|
if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) {
|
|
this.diffs.stop()
|
|
}
|
|
for (const session of orphaned) {
|
|
this.panel?.sessions.clearSessionDirectory(session.id)
|
|
}
|
|
this.clearStaleTracking(worktreeId)
|
|
this.pushState()
|
|
this.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`)
|
|
return null
|
|
}
|
|
|
|
/** Promote a session: create a worktree and move the session into it. */
|
|
private async onPromoteSession(sessionId: string): Promise<null> {
|
|
await this.waitForStateReady("onPromoteSession")
|
|
const created = await this.createWorktreeOnDisk({})
|
|
if (!created) return null
|
|
|
|
// Run setup script for new worktree (blocks until complete, shows in overlay)
|
|
await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id)
|
|
|
|
const state = this.getStateManager()!
|
|
if (!state.getSession(sessionId)) {
|
|
state.addSession(sessionId, created.worktree.id)
|
|
} else {
|
|
state.moveSession(sessionId, created.worktree.id)
|
|
}
|
|
|
|
this.registerWorktreeSession(sessionId, created.result.path)
|
|
await this.recordPromotionHandoff(sessionId, created.result.path, created.result.branch)
|
|
this.notifyWorktreeReady(sessionId, created.result, created.worktree.id)
|
|
this.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`)
|
|
return null
|
|
}
|
|
|
|
private async recordPromotionHandoff(sessionId: string, dir: string, branch: string): Promise<void> {
|
|
try {
|
|
await recordPromotionHandoff({
|
|
client: this.connectionService.getClient(),
|
|
sessionId,
|
|
directory: dir,
|
|
branch,
|
|
})
|
|
} catch (err) {
|
|
this.log("Failed to record worktree promotion handoff:", getErrorMessage(err))
|
|
}
|
|
}
|
|
|
|
/** Add a new session to an existing worktree. */
|
|
private async onAddSessionToWorktree(worktreeId: string, sessionId?: string): Promise<null> {
|
|
let client: KiloClient
|
|
try {
|
|
client = this.connectionService.getClient()
|
|
} catch (err) {
|
|
this.log("onAddSessionToWorktree: client not available:", err)
|
|
this.postToWebview({ type: "error", message: "Not connected to CLI backend" })
|
|
return null
|
|
}
|
|
|
|
const state = this.getStateManager()
|
|
if (!state) return null
|
|
|
|
const worktree = state.getWorktree(worktreeId)
|
|
if (!worktree) {
|
|
this.log(`Worktree ${worktreeId} not found`)
|
|
return null
|
|
}
|
|
|
|
if (sessionId) {
|
|
if (state.getSession(sessionId)) state.moveSession(sessionId, worktreeId)
|
|
else state.addSession(sessionId, worktreeId)
|
|
this.registerWorktreeSession(sessionId, worktree.path)
|
|
this.pushState()
|
|
this.postToWebview({
|
|
type: "agentManager.sessionAdded",
|
|
sessionId,
|
|
worktreeId,
|
|
})
|
|
this.host.capture("Agent Manager Session Started", {
|
|
source: PLATFORM,
|
|
sessionId,
|
|
worktreeId,
|
|
existing: true,
|
|
})
|
|
this.log(`Added existing session ${sessionId} to worktree ${worktreeId}`)
|
|
return null
|
|
}
|
|
|
|
let session: Session
|
|
try {
|
|
const { data } = await client.session.create(
|
|
{ directory: worktree.path, platform: PLATFORM },
|
|
{ throwOnError: true },
|
|
)
|
|
session = data
|
|
} catch (error) {
|
|
const err = getErrorMessage(error)
|
|
this.postToWebview({ type: "error", message: `Failed to create session: ${err}` })
|
|
this.host.capture("Agent Manager Session Error", {
|
|
source: PLATFORM,
|
|
error: err,
|
|
context: "addSessionToWorktree",
|
|
worktreeId,
|
|
})
|
|
return null
|
|
}
|
|
|
|
state.addSession(session.id, worktreeId)
|
|
this.registerWorktreeSession(session.id, worktree.path)
|
|
this.pushState()
|
|
this.postToWebview({
|
|
type: "agentManager.sessionAdded",
|
|
sessionId: session.id,
|
|
worktreeId,
|
|
})
|
|
|
|
if (this.panel) {
|
|
this.panel.sessions.registerSession(session)
|
|
}
|
|
|
|
this.host.capture("Agent Manager Session Started", {
|
|
source: PLATFORM,
|
|
sessionId: session.id,
|
|
worktreeId,
|
|
})
|
|
this.log(`Added session ${session.id} to worktree ${worktreeId}`)
|
|
return null
|
|
}
|
|
|
|
private onForkSession(sessionId: string, worktreeId?: string, messageId?: string) {
|
|
return forkSession(
|
|
{
|
|
getClient: () => this.connectionService.getClient(),
|
|
state: this.getStateManager(),
|
|
directory: this.getRoot(),
|
|
postError: (msg) => this.postToWebview({ type: "error", message: msg }),
|
|
registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir),
|
|
pushState: () => this.pushState(),
|
|
notifyForked: (s, from, wt) =>
|
|
this.postToWebview({
|
|
type: "agentManager.sessionForked",
|
|
sessionId: s.id,
|
|
forkedFromId: from,
|
|
worktreeId: wt,
|
|
}),
|
|
registerSession: (s) => this.panel?.sessions.registerSession(s),
|
|
log: (...args) => this.log(...args),
|
|
},
|
|
sessionId,
|
|
worktreeId,
|
|
messageId,
|
|
)
|
|
}
|
|
|
|
/** Close (remove) a session from its worktree. */
|
|
private async onCloseSession(sessionId: string): Promise<null> {
|
|
const state = this.getStateManager()
|
|
if (!state) return null
|
|
|
|
const dirs = this.panel?.sessions.getSessionDirectories()
|
|
const dir = state.directoryFor(sessionId) ?? dirs?.get(sessionId) ?? this.getRoot() ?? process.cwd()
|
|
try {
|
|
await stopSessionProcesses(this.connectionService.getClient(), sessionId, dir)
|
|
} catch (err) {
|
|
this.log("onCloseSession: client not available:", err)
|
|
}
|
|
|
|
state.removeSession(sessionId)
|
|
this.panel?.sessions.clearSessionDirectory(sessionId)
|
|
this.pushState()
|
|
this.log(`Closed session ${sessionId}`)
|
|
return null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multi-version worktree creation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Create N worktree sessions for the same prompt (multi-version mode). */
|
|
private async onCreateMultiVersion(
|
|
msg: Extract<AgentManagerInMessage, { type: "agentManager.createMultiVersion" }>,
|
|
): Promise<null> {
|
|
await this.waitForStateReady("onCreateMultiVersion")
|
|
const text = msg.text?.trim() || undefined
|
|
|
|
const worktreeName = msg.name?.trim() || undefined
|
|
const agent = msg.agent
|
|
const files = msg.files
|
|
const baseBranch = msg.baseBranch
|
|
const branchName = msg.branchName?.trim() || undefined
|
|
|
|
const fallback = msg.providerID && msg.modelID ? { providerID: msg.providerID, modelID: msg.modelID } : undefined
|
|
const resolved = resolveVersionModels(msg.modelAllocations, fallback, Number(msg.versions) || 1)
|
|
const { models, versions, providerID, modelID } = resolved
|
|
|
|
// Generate a shared group ID for multi-version worktrees
|
|
const groupId = versions > 1 ? `grp-${Date.now()}` : undefined
|
|
|
|
this.log(
|
|
`Creating ${versions} worktrees${models.length > 0 ? " (model comparison)" : ""}${text ? ` for: ${text.slice(0, 60)}` : ""}${groupId ? ` (group=${groupId})` : ""}`,
|
|
)
|
|
|
|
// Notify webview that multi-version creation has started
|
|
this.postToWebview({
|
|
type: "agentManager.multiVersionProgress",
|
|
status: "creating",
|
|
total: versions,
|
|
completed: 0,
|
|
groupId,
|
|
})
|
|
|
|
// Phase 1: Create all worktrees + sessions first
|
|
const created: CreatedVersion[] = []
|
|
|
|
for (let i = 0; i < versions; i++) {
|
|
this.log(`Creating worktree ${i + 1}/${versions}`)
|
|
|
|
const version = versionedName(branchName || worktreeName, i, versions)
|
|
const wt = await this.createWorktreeOnDisk({
|
|
groupId,
|
|
baseBranch,
|
|
branchName: version.branch,
|
|
name: version.branch,
|
|
label: version.label,
|
|
})
|
|
if (!wt) {
|
|
this.log(`Failed to create worktree for version ${i + 1}`)
|
|
continue
|
|
}
|
|
|
|
await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch, wt.worktree.id)
|
|
|
|
const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch, wt.worktree.id)
|
|
if (!session) {
|
|
const state = this.getStateManager()
|
|
const manager = this.getWorktreeManager()
|
|
state?.removeWorktree(wt.worktree.id)
|
|
await manager?.removeWorktree(wt.result.path)
|
|
this.log(`Failed to create session for version ${i + 1}`)
|
|
continue
|
|
}
|
|
|
|
const state = this.getStateManager()!
|
|
state.addSession(session.id, wt.worktree.id)
|
|
this.registerWorktreeSession(session.id, wt.result.path)
|
|
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.
|
|
// Uses a dedicated message type to avoid clearing the busy state.
|
|
const versionModel = models[i]
|
|
const earlyProviderID = versionModel?.providerID ?? providerID
|
|
const earlyModelID = versionModel?.modelID ?? modelID
|
|
if (earlyProviderID && earlyModelID) {
|
|
this.postToWebview({
|
|
type: "agentManager.setSessionModel",
|
|
sessionId: session.id,
|
|
providerID: earlyProviderID,
|
|
modelID: earlyModelID,
|
|
})
|
|
}
|
|
|
|
created.push({
|
|
worktreeId: wt.worktree.id,
|
|
sessionId: session.id,
|
|
path: wt.result.path,
|
|
branch: wt.result.branch,
|
|
parentBranch: wt.result.parentBranch,
|
|
versionIndex: i,
|
|
})
|
|
|
|
this.host.capture("Agent Manager Session Started", {
|
|
source: PLATFORM,
|
|
sessionId: session.id,
|
|
worktreeId: wt.worktree.id,
|
|
branch: wt.result.branch,
|
|
multiVersion: true,
|
|
version: i + 1,
|
|
totalVersions: versions,
|
|
groupId,
|
|
})
|
|
this.log(`Version ${i + 1} worktree ready: session=${session.id}`)
|
|
|
|
// Update progress
|
|
this.postToWebview({
|
|
type: "agentManager.multiVersionProgress",
|
|
status: "creating",
|
|
total: versions,
|
|
completed: created.length,
|
|
groupId,
|
|
})
|
|
}
|
|
|
|
// Phase 2: Send the initial prompt to all sessions, or clear busy state if no text.
|
|
const messages = buildInitialMessages(created, models, { providerID, modelID }, text, agent, msg.variant, files)
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const msg = messages[i]!
|
|
if (text) {
|
|
this.log(`Sending initial message to version ${i + 1} (session=${msg.sessionId})`)
|
|
}
|
|
this.postToWebview({ type: "agentManager.sendInitialMessage", ...msg })
|
|
if (text && i < messages.length - 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
}
|
|
}
|
|
|
|
// Notify completion
|
|
this.postToWebview({
|
|
type: "agentManager.multiVersionProgress",
|
|
status: "done",
|
|
total: versions,
|
|
completed: created.length,
|
|
groupId,
|
|
})
|
|
|
|
if (created.length === 0) {
|
|
this.host.showError(`Failed to create any of the ${versions} multi-version worktrees.`)
|
|
}
|
|
|
|
this.log(`Multi-version creation complete: ${created.length}/${versions} versions`)
|
|
return null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keybindings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private sendKeybindings(): void {
|
|
const keybindings = this.host.extensionKeybindings()
|
|
const bindings = buildKeybindingMap(keybindings, process.platform === "darwin")
|
|
this.postToWebview({ type: "agentManager.keybindings", bindings })
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup script
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Open the worktree setup script in the editor for user configuration. */
|
|
private async configureSetupScript(): Promise<void> {
|
|
const service = this.getSetupScriptService()
|
|
if (!service) return
|
|
try {
|
|
if (!service.hasScript()) {
|
|
await service.createDefaultScript()
|
|
}
|
|
const resolved = service.resolveScript()
|
|
if (!resolved) return
|
|
await this.host.openDocument(resolved.path)
|
|
} catch (error) {
|
|
this.log(`Failed to open setup script: ${error}`)
|
|
}
|
|
}
|
|
|
|
/** Copy .env files and run the worktree setup script. Blocks until complete. Shows progress in overlay. */
|
|
private async runSetupScriptForWorktree(worktreePath: string, branch?: string, worktreeId?: string): Promise<void> {
|
|
const root = this.getRoot()
|
|
if (!root) return
|
|
|
|
// Always copy .env files from the main repo (before the setup script so it can override)
|
|
await copyEnvFiles(root, worktreePath, (msg) => this.outputChannel.appendLine(`[EnvCopy] ${msg}`))
|
|
|
|
try {
|
|
const service = this.getSetupScriptService()
|
|
if (!service || !service.hasScript()) return
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "creating",
|
|
message: "Running setup script...",
|
|
branch,
|
|
worktreeId,
|
|
})
|
|
const runner = new SetupScriptRunner(
|
|
(msg) => this.outputChannel.appendLine(`[SetupScriptRunner] ${msg}`),
|
|
service,
|
|
executeVscodeTask,
|
|
)
|
|
await runner.runIfConfigured({ worktreePath, repoPath: root })
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error)
|
|
this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`)
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "error",
|
|
message: `Setup script failed: ${msg}`,
|
|
branch,
|
|
worktreeId,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Repo info
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private async sendRepoInfo(): Promise<void> {
|
|
const manager = this.getWorktreeManager()
|
|
if (!manager) return
|
|
try {
|
|
const branch = await manager.currentBranch()
|
|
const defaultBranch = await manager.defaultBranch()
|
|
this.postToWebview({ type: "agentManager.repoInfo", branch, defaultBranch })
|
|
} catch (error) {
|
|
this.log(`Failed to get current branch: ${error}`)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private registerWorktreeSession(sessionId: string, directory: string): void {
|
|
const worktree = this.state?.findWorktreeByPath(directory)
|
|
if (worktree) this.writeMetadata(sessionId, worktree)
|
|
|
|
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 writeMetadata(sessionId: string, worktree: Worktree): void {
|
|
const manager = this.getWorktreeManager()
|
|
if (!manager) return
|
|
void manager
|
|
.writeMetadata(worktree.path, sessionId, worktree.parentBranch, worktree.remote)
|
|
.catch((err) => this.log(`Failed to write worktree metadata for ${worktree.id}:`, err))
|
|
}
|
|
|
|
/** Route a plan follow-up session to its worktree instead of LOCAL. */
|
|
private adoptFollowupInWorktree(session: Session, directory: string): void {
|
|
const state = this.getStateManager()
|
|
if (!state) return
|
|
const worktree = state.findWorktreeByPath(directory)
|
|
if (!worktree) return
|
|
|
|
state.addSession(session.id, worktree.id)
|
|
this.registerWorktreeSession(session.id, directory)
|
|
this.pushState()
|
|
this.postToWebview({
|
|
type: "agentManager.sessionAdded",
|
|
sessionId: session.id,
|
|
worktreeId: worktree.id,
|
|
})
|
|
this.log(`Adopted follow-up session ${session.id} into worktree ${worktree.id}`)
|
|
}
|
|
|
|
private onWorktreePresence(result: WorktreePresenceResult): void {
|
|
const state = this.state
|
|
if (!state) return
|
|
|
|
const worktrees = state.getWorktrees()
|
|
const ids = new Set(worktrees.map((wt) => wt.id))
|
|
this.pruneStaleWorktreeIds(ids)
|
|
|
|
if (result.degraded) {
|
|
this.log("Skipping stale worktree update: degraded worktree probe")
|
|
return
|
|
}
|
|
|
|
const entries = result.worktrees.filter((item) => ids.has(item.worktreeId))
|
|
if (entries.length === 0) return
|
|
|
|
// Sync branches from git worktree list (no extra git calls)
|
|
let branchChanged = false
|
|
for (const entry of entries) {
|
|
if (entry.branch && state.updateWorktreeBranch(entry.worktreeId, entry.branch)) {
|
|
branchChanged = true
|
|
}
|
|
}
|
|
|
|
const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId))
|
|
const staleChanged =
|
|
next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId))
|
|
this.staleWorktreeIds = next
|
|
|
|
if (staleChanged || branchChanged) {
|
|
this.pushState()
|
|
}
|
|
}
|
|
|
|
private clearStaleTracking(worktreeId: string): void {
|
|
this.staleWorktreeIds.delete(worktreeId)
|
|
}
|
|
|
|
private staleWorktreesForState(worktrees: ReturnType<WorktreeStateManager["getWorktrees"]>): string[] {
|
|
const ids = new Set(worktrees.map((wt) => wt.id))
|
|
this.pruneStaleWorktreeIds(ids)
|
|
return worktrees.filter((wt) => this.staleWorktreeIds.has(wt.id)).map((wt) => wt.id)
|
|
}
|
|
|
|
private pruneStaleWorktreeIds(ids: Set<string>): void {
|
|
for (const id of [...this.staleWorktreeIds]) {
|
|
if (ids.has(id)) continue
|
|
this.staleWorktreeIds.delete(id)
|
|
}
|
|
}
|
|
|
|
/** Sync the poller's skip set with currently collapsed sections. */
|
|
private syncPollerSkips(): void {
|
|
const state = this.state
|
|
if (!state) return
|
|
const skipped = new Set<string>()
|
|
for (const sec of state.getSections()) {
|
|
if (!sec.collapsed) continue
|
|
for (const id of state.getWorktreesInSection(sec.id)) skipped.add(id)
|
|
}
|
|
const stats = this.statsPoller.syncSkips(skipped)
|
|
if (!stats) return
|
|
const msg = { type: "agentManager.worktreeStats" as const, stats }
|
|
this.cachedWorktreeStats = msg
|
|
this.postToWebview(msg)
|
|
}
|
|
|
|
private pushState(): void {
|
|
const state = this.state
|
|
if (!state) return
|
|
const worktrees = state.getWorktrees()
|
|
const staleWorktreeIds = this.staleWorktreesForState(worktrees)
|
|
const run = this.run.state()
|
|
this.postToWebview({
|
|
type: "agentManager.state",
|
|
worktrees,
|
|
sessions: state.getSessions(),
|
|
sections: state.getSections(),
|
|
staleWorktreeIds,
|
|
tabOrder: state.getTabOrder(),
|
|
worktreeOrder: state.getWorktreeOrder(),
|
|
sessionsCollapsed: state.getSessionsCollapsed(),
|
|
sidebarCollapsed: state.getSidebarCollapsed(),
|
|
reviewDiffStyle: state.getReviewDiffStyle(),
|
|
reviewMarkdownRender: getDiffMarkdownRender(),
|
|
isGitRepo: true,
|
|
defaultBaseBranch: state.getDefaultBaseBranch(),
|
|
...run,
|
|
})
|
|
|
|
// Sync skip set before enabling the poller so the first poll cycle
|
|
// already excludes worktrees in collapsed sections.
|
|
this.syncPollerSkips()
|
|
this.statsPoller.setEnabled(worktrees.length > 0 || this.panel !== undefined)
|
|
this.prBridge.poller.setEnabled(worktrees.length > 0)
|
|
}
|
|
|
|
/** Push empty state when the folder is not a git repo or has no folder open. */
|
|
private pushEmptyState(): void {
|
|
this.staleWorktreeIds.clear()
|
|
this.postToWebview({
|
|
type: "agentManager.state",
|
|
worktrees: [],
|
|
sessions: [],
|
|
staleWorktreeIds: [],
|
|
reviewDiffStyle: "unified",
|
|
reviewMarkdownRender: getDiffMarkdownRender(),
|
|
isGitRepo: false,
|
|
runStatuses: [],
|
|
runScriptConfigured: false,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manager accessors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private getRoot(): string | undefined {
|
|
return this.host.workspacePath()
|
|
}
|
|
|
|
private getWorktreeManager(): WorktreeManager | undefined {
|
|
if (this.worktrees) return this.worktrees
|
|
const root = this.getRoot()
|
|
if (!root) {
|
|
this.log("getWorktreeManager: no folder available")
|
|
return undefined
|
|
}
|
|
this.worktrees = new WorktreeManager(
|
|
root,
|
|
(msg) => this.outputChannel.appendLine(`[WorktreeManager] ${msg}`),
|
|
this.gitOps,
|
|
)
|
|
return this.worktrees
|
|
}
|
|
|
|
private getStateManager(): WorktreeStateManager | undefined {
|
|
if (this.state) return this.state
|
|
const root = this.getRoot()
|
|
if (!root) {
|
|
this.log("getStateManager: no folder available")
|
|
return undefined
|
|
}
|
|
this.state = new WorktreeStateManager(root, (msg) => this.outputChannel.appendLine(`[StateManager] ${msg}`))
|
|
return this.state
|
|
}
|
|
|
|
private getSetupScriptService(): SetupScriptService | undefined {
|
|
if (this.setupScript) return this.setupScript
|
|
const root = this.getRoot()
|
|
if (!root) {
|
|
this.log("getSetupScriptService: no folder available")
|
|
return undefined
|
|
}
|
|
this.setupScript = new SetupScriptService(root)
|
|
return this.setupScript
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Worktree file helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Open a worktree directory directly in VS Code. */
|
|
private openWorktreeDirectory(worktreeId: string): void {
|
|
const state = this.getStateManager()
|
|
if (!state) return
|
|
const worktree = state.getWorktree(worktreeId)
|
|
if (!worktree) return
|
|
const target = path.normalize(worktree.path)
|
|
if (!fs.existsSync(target)) {
|
|
this.log(`openWorktreeDirectory: missing path ${target}`)
|
|
this.host.showError("Worktree folder does not exist on disk.")
|
|
return
|
|
}
|
|
this.host.openFolder(target, true)
|
|
}
|
|
|
|
/** Open a file from a worktree or local session in the VS Code editor.
|
|
* Absolute paths (Unix `/…` or Windows `C:\…`) are opened directly.
|
|
* Relative paths are resolved against the session's worktree directory
|
|
* (or repo root for local sessions) with symlink-traversal protection. */
|
|
private openWorktreeFile(sessionId: string, filePath: string, line?: number, column?: number): void {
|
|
if (isAbsolutePath(filePath)) {
|
|
this.host.openFile(filePath, line, column)
|
|
return
|
|
}
|
|
const state = this.getStateManager()
|
|
if (!state) return
|
|
const session = state.getSession(sessionId)
|
|
const base = session?.worktreeId ? state.getWorktree(session.worktreeId)?.path : this.getRoot()
|
|
if (!base) return
|
|
// Resolve real paths to prevent symlink traversal and normalize for
|
|
// consistent comparison on both Unix and Windows.
|
|
let resolved: string
|
|
try {
|
|
const root = fs.realpathSync(base)
|
|
resolved = fs.realpathSync(path.resolve(base, filePath))
|
|
// Directory-boundary check: append path.sep so "/foo/bar" won't match "/foo/bar2/..."
|
|
if (resolved !== root && !resolved.startsWith(root + path.sep)) return
|
|
} catch (err) {
|
|
console.error("[Kilo New] AgentManagerProvider: Cannot resolve file path:", err)
|
|
return
|
|
}
|
|
this.host.openFile(resolved, line, column)
|
|
}
|
|
|
|
private postToWebview(message: AgentManagerOutMessage): void {
|
|
this.panel?.postMessage(message)
|
|
}
|
|
|
|
/**
|
|
* Reveal the Agent Manager panel and focus the prompt input.
|
|
* Used for the keyboard shortcut to switch back from terminal.
|
|
*/
|
|
public focusPanel(): void {
|
|
if (!this.panel) return
|
|
this.panel.reveal(false)
|
|
this.postToWebview({ type: "action", action: "focusInput" })
|
|
}
|
|
|
|
public isActive(): boolean {
|
|
return this.panel?.active === true
|
|
}
|
|
|
|
/** Expose worktree session→directory mappings for the auto-approve toggle. */
|
|
public getSessionDirectories(): ReadonlyMap<string, string> {
|
|
return this.panel?.sessions.getSessionDirectories() ?? new Map()
|
|
}
|
|
|
|
public getWorktreeDirectories(): string[] {
|
|
return (
|
|
this.getStateManager()
|
|
?.getWorktrees()
|
|
.map((wt) => wt.path) ?? []
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Continue a sidebar session in a new worktree.
|
|
* Captures git state, creates worktree, applies state, forks session.
|
|
* Called from KiloProvider when the sidebar sends "continueInWorktree".
|
|
*/
|
|
public async continueFromSidebar(
|
|
sessionId: string,
|
|
progress: (status: string, detail?: string, error?: string) => void,
|
|
): Promise<void> {
|
|
const root = this.getRoot()
|
|
if (!root) {
|
|
progress("error", undefined, "No workspace folder open")
|
|
return
|
|
}
|
|
|
|
this.openPanel()
|
|
await this.waitForStateReady("continueFromSidebar")
|
|
|
|
await continueInWorktree(
|
|
{
|
|
root,
|
|
getClient: () => this.connectionService.getClient(),
|
|
createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts),
|
|
runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id),
|
|
cleanupWorktree: async (id) => {
|
|
await this.onDeleteWorktree(id)
|
|
},
|
|
notifyError: (error, result, id) => {
|
|
this.postToWebview({
|
|
type: "agentManager.worktreeSetup",
|
|
status: "error",
|
|
message: error,
|
|
branch: result.branch,
|
|
worktreeId: id,
|
|
})
|
|
},
|
|
getStateManager: () => this.getStateManager(),
|
|
registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir),
|
|
registerSession: (session) => this.panel?.sessions.registerSession(session),
|
|
notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid),
|
|
capture: (event, props) => this.host.capture(event, props),
|
|
log: (...args) => this.log(...args),
|
|
},
|
|
sessionId,
|
|
progress,
|
|
)
|
|
}
|
|
|
|
public async createFromSidebar(baseBranch?: string, branchName?: string): Promise<void> {
|
|
this.openPanel()
|
|
const panel = this.panel
|
|
if (!panel) return
|
|
await panel.waitForReady()
|
|
await this.waitForStateReady("createFromSidebar")
|
|
await this.onCreateWorktree(baseBranch, branchName)
|
|
}
|
|
|
|
public async openAdvancedWorktree(): Promise<void> {
|
|
this.openPanel()
|
|
const panel = this.panel
|
|
if (!panel) return
|
|
await panel.waitForActive()
|
|
await panel.waitForReady()
|
|
await this.waitForStateReady("openAdvancedWorktree")
|
|
queueMicrotask(() => this.postToWebview({ type: "action", action: "advancedWorktree" }))
|
|
}
|
|
|
|
private handleSection(m: AgentManagerInMessage): boolean {
|
|
return handleSection(this.state, m, () => this.pushState())
|
|
}
|
|
|
|
public postMessage(message: unknown): void {
|
|
this.panel?.postMessage(message)
|
|
}
|
|
|
|
public shutdown(): Promise<void> {
|
|
if (!this.closing) this.closing = this.disposeAsync()
|
|
return this.closing
|
|
}
|
|
|
|
public dispose(): void {
|
|
void this.shutdown()
|
|
}
|
|
|
|
private async disposeAsync(): Promise<void> {
|
|
await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err))
|
|
await this.state?.flush().catch((err) => this.log("dispose: state flush failed:", err))
|
|
this.unsubTool?.()
|
|
this.unsubFont?.()
|
|
this.connectionService.unregisterFocused("agent-manager")
|
|
this.connectionService.registerOpen("agent-manager", [])
|
|
this.diffs.stop()
|
|
this.statsPoller.stop()
|
|
this.gitOps.dispose()
|
|
this.prBridge.poller.stop()
|
|
this.run.dispose()
|
|
this.terminalManager.dispose()
|
|
await this.terminalRouter.dispose()
|
|
this.panel?.dispose()
|
|
this.outputChannel.dispose()
|
|
this.host.dispose()
|
|
}
|
|
}
|