Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/humble-points-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix: bottom controls no longer overlap with create mode button
5 changes: 5 additions & 0 deletions .changeset/tidy-agent-manager-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Handle different cli authentication errors when using agent manager
5 changes: 5 additions & 0 deletions packages/cloud/src/StaticTokenAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export class StaticTokenAuthService extends EventEmitter<AuthServiceEvents> impl
let payload

try {
const parts = typeof token === "string" ? token.split(".") : []
if (parts.length !== 3 || parts.some((p) => p.length === 0)) {
throw new Error("Invalid JWT format")
}

payload = jwtDecode<JWTPayload>(token)
} catch (error) {
this.log("[auth] Failed to parse JWT:", error)
Expand Down
135 changes: 120 additions & 15 deletions src/core/kilocode/agent-manager/AgentManagerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { findKilocodeCli } from "./CliPathResolver"
import { canInstallCli, getCliInstallCommand, getLocalCliInstallCommand, getLocalCliBinDir } from "./CliInstaller"
import { CliProcessHandler, type CliProcessHandlerCallbacks } from "./CliProcessHandler"
import type { StreamEvent, KilocodeStreamEvent, KilocodePayload, WelcomeStreamEvent } from "./CliOutputParser"
import { extractRawText, tryParsePayloadJson } from "./askErrorParser"
import { RemoteSessionService } from "./RemoteSessionService"
import { KilocodeEventProcessor } from "./KilocodeEventProcessor"
import type { RemoteSession } from "./types"
Expand Down Expand Up @@ -52,6 +53,7 @@ export class AgentManagerProvider implements vscode.Disposable {
private firstApiReqStarted: Map<string, boolean> = new Map()
// Track the current workspace's git URL for filtering sessions
private currentGitUrl: string | undefined
private lastAuthErrorMessage: string | undefined
// Track process start times to filter out replayed history events
private processStartTimes: Map<string, number> = new Map()

Expand Down Expand Up @@ -80,6 +82,14 @@ export class AgentManagerProvider implements vscode.Disposable {
},
onStartSessionFailed: (error) => {
this.postMessage({ type: "agentManager.startSessionFailed" })
if (error?.type === "payment_required") {
this.showPaymentRequiredPrompt(error.payload ?? { text: error.message })
return
}
if (error?.type === "api_req_failed") {
this.handleStartSessionApiFailure(error)
return
}
this.showCliError(error)
},
onChatMessages: (sessionId, messages) => {
Expand Down Expand Up @@ -123,6 +133,7 @@ export class AgentManagerProvider implements vscode.Disposable {
eventType: "ask_completion_result",
})
},
onPaymentRequiredPrompt: (payload) => this.showPaymentRequiredPrompt(payload),
}

this.processHandler = new CliProcessHandler(this.registry, callbacks)
Expand All @@ -137,6 +148,7 @@ export class AgentManagerProvider implements vscode.Disposable {
postState: () => this.postStateToWebview(),
postStateEvent: (sessionId, payload) =>
this.postMessage({ type: "agentManager.stateEvent", sessionId, ...payload }),
onPaymentRequiredPrompt: (payload) => this.showPaymentRequiredPrompt(payload),
})
}

Expand Down Expand Up @@ -932,35 +944,128 @@ export class AgentManagerProvider implements vscode.Disposable {
this.disposables.forEach((d) => d.dispose())
}

private showPaymentRequiredPrompt(payload?: KilocodePayload | { text?: string; content?: string }): void {
const { title, message, buyCreditsUrl, rawText } = this.parsePaymentRequiredPayload(payload)

const actionLabel = buyCreditsUrl ? "Open billing" : undefined
const actions = actionLabel ? [actionLabel] : []

this.outputChannel.appendLine(`[AgentManager] Payment required: ${message}`)

void vscode.window.showWarningMessage(`${title}: ${message}`, ...actions).then((selection) => {
if (selection === actionLabel && buyCreditsUrl) {
void vscode.env.openExternal(vscode.Uri.parse(buyCreditsUrl))
}
})
}

private showCliNotFoundError(): void {
this.showCliError({ type: "spawn_error", message: "CLI not found" })
}

/**
* Open a terminal and run the CLI install command (global installation).
* Uses the terminal to ensure the user's shell environment (nvm, fnm, volta, etc.) is respected.
*/
private runInstallInTerminal(): void {
private createCliTerminal(name: string, message?: string): vscode.Terminal | null {
if (typeof vscode.window.createTerminal !== "function") {
this.outputChannel.appendLine(`[AgentManager] VS Code terminal unavailable; run "kilocode auth" manually.`)
return null
}

const shellPath = process.platform === "win32" ? undefined : process.env.SHELL
const shellName = shellPath ? path.basename(shellPath) : undefined
const shellArgs = process.platform === "win32" ? undefined : shellName === "zsh" ? ["-l", "-i"] : ["-l"]

const terminal = vscode.window.createTerminal({
name: "Install Kilocode CLI",
message: t("kilocode:agentManager.terminal.installMessage"),
return vscode.window.createTerminal({
name,
message,
shellPath,
shellArgs,
})
}

/**
* Open a terminal and run the CLI install command (global installation).
* Uses the terminal to ensure the user's shell environment (nvm, fnm, volta, etc.) is respected.
*/
private runInstallInTerminal(): void {
const terminal = this.createCliTerminal(
"Install Kilocode CLI",
t("kilocode:agentManager.terminal.installMessage"),
)
if (!terminal) {
return
}
terminal.show()
terminal.sendText(getCliInstallCommand())
this.showCliAuthReminder()
}

private runAuthInTerminal(): void {
const terminal = this.createCliTerminal("Kilocode CLI Login")
if (!terminal) {
return
}
terminal.show()
terminal.sendText("kilocode auth")
}

private showCliAuthReminder(message?: string): void {
const authLabel = t("kilocode:agentManager.actions.loginCli")
void vscode.window
.showInformationMessage(t("kilocode:agentManager.terminal.authReminder"), authLabel)
.then((selection) => {
if (selection === authLabel) {
terminal.sendText("kilocode auth")
}
})
const combined = this.buildAuthReminderMessage(message)
this.outputChannel.appendLine(`[AgentManager] ${combined}`)
void vscode.window.showWarningMessage(combined, authLabel).then((selection) => {
if (selection === authLabel) {
this.runAuthInTerminal()
}
})
}

private buildAuthReminderMessage(message?: string): string {
const reminder = t("kilocode:agentManager.terminal.authReminder")
const base = message || ""
return base ? `${base}\n\n${reminder}` : reminder
}

private handleStartSessionApiFailure(error: { message?: string; authError?: boolean }): void {
const message =
error.authError === true
? this.buildAuthReminderMessage(error.message || t("kilocode:agentManager.errors.sessionFailed"))
: error.message || t("kilocode:agentManager.errors.sessionFailed")
if (error.authError && message && message === this.lastAuthErrorMessage) {
return
}

const authLabel = error.authError ? t("kilocode:agentManager.actions.loginCli") : undefined
const actions = authLabel ? [authLabel] : []
void vscode.window.showWarningMessage(message, ...actions).then((selection) => {
if (selection === authLabel) {
this.runAuthInTerminal()
}
})
if (error.authError) {
this.lastAuthErrorMessage = message
}
}

private parsePaymentRequiredPayload(payload?: KilocodePayload | { text?: string; content?: string }): {
title: string
message: string
buyCreditsUrl?: string
rawText?: string
} {
const fallbackTitle = t("kilocode:lowCreditWarning.title")
const fallbackMessage = t("kilocode:lowCreditWarning.message")

const rawText = payload ? extractRawText(payload) : undefined
const parsed = rawText ? tryParsePayloadJson(rawText) : undefined

const title =
parsed?.title || (typeof fallbackTitle === "string" ? fallbackTitle : undefined) || "Payment required"
const message =
parsed?.message ||
rawText ||
(typeof fallbackMessage === "string" ? fallbackMessage : undefined) ||
"Paid model requires credits or billing setup."

return { title, message, buyCreditsUrl: parsed?.buyCreditsUrl, rawText }
}

/**
Expand Down
63 changes: 62 additions & 1 deletion src/core/kilocode/agent-manager/CliProcessHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
type SessionCreatedStreamEvent,
type WelcomeStreamEvent,
type KilocodeStreamEvent,
type KilocodePayload,
} from "./CliOutputParser"
import { AgentRegistry } from "./AgentRegistry"
import { buildCliArgs } from "./CliArgsBuilder"
import type { ClineMessage } from "@roo-code/types"
import { extractApiReqFailedMessage, extractPayloadMessage } from "./askErrorParser"

/**
* Timeout for pending sessions (ms) - if session_created event doesn't arrive within this time,
Expand Down Expand Up @@ -47,9 +49,15 @@ export interface CliProcessHandlerCallbacks {
onSessionLog: (sessionId: string, line: string) => void
onStateChanged: () => void
onPendingSessionChanged: (pendingSession: { prompt: string; label: string; startTime: number } | null) => void
onStartSessionFailed: (error?: { type: "cli_outdated" | "spawn_error" | "unknown"; message: string }) => void
onStartSessionFailed: (
error?:
| { type: "cli_outdated" | "spawn_error" | "unknown"; message: string }
| { type: "api_req_failed"; message: string; payload?: KilocodePayload; authError?: boolean }
| { type: "payment_required"; message: string; payload?: KilocodePayload },
) => void
onChatMessages: (sessionId: string, messages: ClineMessage[]) => void
onSessionCreated: (sawApiReqStarted: boolean) => void
onPaymentRequiredPrompt?: (payload: KilocodePayload) => void
onSessionCompleted?: (sessionId: string, exitCode: number | null) => void // Called when process exits successfully
}

Expand Down Expand Up @@ -316,6 +324,14 @@ export class CliProcessHandler {
// This is needed so KilocodeEventProcessor knows the user echo has already happened
if (event.streamEventType === "kilocode") {
const payload = (event as KilocodeStreamEvent).payload
if (payload?.ask === "payment_required_prompt") {
this.handlePaymentRequiredDuringPending(payload)
return
}
if (payload?.ask === "api_req_failed") {
this.handleApiReqFailedDuringPending(payload)
return
}
if (payload?.say === "api_req_started") {
this.pendingProcess.sawApiReqStarted = true
this.debugLog(`Captured api_req_started before session_created`)
Expand Down Expand Up @@ -520,6 +536,51 @@ export class CliProcessHandler {
return null
}

private handlePaymentRequiredDuringPending(payload: KilocodePayload): void {
this.handlePendingAskFailure(payload, "payment_required", () => ({
message: extractPayloadMessage(payload, "Paid model requires credits or billing setup."),
}))
}

private handleApiReqFailedDuringPending(payload: KilocodePayload): void {
this.handlePendingAskFailure(payload, "api_req_failed", () => extractApiReqFailedMessage(payload))
}

private handlePendingAskFailure(
payload: KilocodePayload,
type: "payment_required" | "api_req_failed",
build: () => { message: string; authError?: boolean },
): void {
if (!this.pendingProcess) {
return
}

this.debugLog(`Received ${type} before session_created`)
this.clearPendingAndNotify(true)

const details = build()
this.callbacks.onStartSessionFailed({
type,
payload,
...details,
})
}

private clearPendingAndNotify(killProcess: boolean): void {
if (!this.pendingProcess) {
return
}

this.clearPendingTimeout()
if (killProcess) {
this.pendingProcess.process.kill("SIGTERM")
}
this.registry.clearPendingSession()
this.pendingProcess = null
this.callbacks.onPendingSessionChanged(null)
this.callbacks.onStateChanged()
}

/**
* Detect CLI error type from stderr output.
* Used to provide helpful error messages for version mismatches.
Expand Down
4 changes: 4 additions & 0 deletions src/core/kilocode/agent-manager/KilocodeEventProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface Dependencies {
postChatMessages: (sessionId: string, messages: ClineMessage[]) => void
postState: () => void
postStateEvent: (sessionId: string, payload: StateEventPayload) => void
onPaymentRequiredPrompt?: (payload: KilocodePayload) => void
}

export class KilocodeEventProcessor {
Expand All @@ -31,6 +32,7 @@ export class KilocodeEventProcessor {
private readonly postChatMessages: (sessionId: string, messages: ClineMessage[]) => void
private readonly postState: () => void
private readonly postStateEvent: (sessionId: string, payload: StateEventPayload) => void
private readonly onPaymentRequiredPrompt?: (payload: KilocodePayload) => void

constructor(deps: Dependencies) {
this.processHandler = deps.processHandler
Expand All @@ -41,6 +43,7 @@ export class KilocodeEventProcessor {
this.postChatMessages = deps.postChatMessages
this.postState = deps.postState
this.postStateEvent = deps.postStateEvent
this.onPaymentRequiredPrompt = deps.onPaymentRequiredPrompt
}

public handle(sessionId: string, event: KilocodeStreamEvent): void {
Expand Down Expand Up @@ -140,6 +143,7 @@ export class KilocodeEventProcessor {
return
}

this.onPaymentRequiredPrompt?.(payload)
this.processHandler.stopProcess(sessionId)
const errorText = message.text || "Paid model requires credits or billing setup."
this.registry.updateSessionStatus(sessionId, "error", undefined, errorText)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CliProcessHandler } from "../CliProcessHandler"
vi.mock("vscode", () => {
const window = {
showErrorMessage: vi.fn(),
showWarningMessage: vi.fn(),
}
const Uri = {
joinPath: vi.fn(),
Expand Down
Loading