Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dcf48c1
hijack flow on /session
iscekic Dec 1, 2025
4e6f5d0
add /session share command
iscekic Dec 1, 2025
c3dc993
Merge branch 'main' into add-session-commands
iscekic Dec 1, 2025
095ab3c
add /session fork
iscekic Dec 1, 2025
3521d87
update share url
iscekic Dec 1, 2025
a2e6ed8
fix paths
iscekic Dec 1, 2025
cdefd5e
Merge branch 'main' into add-session-commands
iscekic Dec 2, 2025
085e092
improve messaging if share id not present
iscekic Dec 2, 2025
230bcec
add changesets
iscekic Dec 2, 2025
afcd842
add share session button
iscekic Dec 2, 2025
7d752e9
add env var disabling session functionality
iscekic Dec 2, 2025
46b1745
improve command param naming
iscekic Dec 2, 2025
abb4ebd
improve copy
iscekic Dec 2, 2025
328bf3c
handle /kilo/fork?id=xxx urls
iscekic Dec 2, 2025
ec3d9d9
fix copy
iscekic Dec 2, 2025
36533c6
add translations
iscekic Dec 2, 2025
8230d3a
Merge branch 'main' into add-session-commands
iscekic Dec 2, 2025
b545e27
fix failing cli test
iscekic Dec 2, 2025
c796a4d
improve title summary request
iscekic Dec 3, 2025
030e81a
make the singleton safe to use before dependencies are initialized
iscekic Dec 3, 2025
2bbfd39
correctly reinit singleton timer after destroying
iscekic Dec 3, 2025
7b868e1
regenerate tests
iscekic Dec 3, 2025
dee5916
extract fn for /session command handling
iscekic Dec 3, 2025
806bbcb
rename button component
iscekic Dec 3, 2025
be26d1c
add marker
iscekic Dec 3, 2025
5048d4c
improve usage of SessionManager
iscekic Dec 3, 2025
382b811
add back flow interruption when handling session command
iscekic Dec 3, 2025
aa3c6b5
extract function for finding kilocodeToken
iscekic Dec 3, 2025
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/late-paths-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

update shared session url
5 changes: 5 additions & 0 deletions .changeset/twenty-rocks-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

add session sharing and forking
8 changes: 4 additions & 4 deletions cli/src/commands/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe("sessionCommand", () => {
expect(sessionCommand.examples).toContain("/session search <query>")
expect(sessionCommand.examples).toContain("/session select <sessionId>")
expect(sessionCommand.examples).toContain("/session share")
expect(sessionCommand.examples).toContain("/session fork <shareId>")
expect(sessionCommand.examples).toContain("/session fork <id>")
expect(sessionCommand.examples).toContain("/session delete <sessionId>")
expect(sessionCommand.examples).toContain("/session rename <new name>")
})
Expand Down Expand Up @@ -621,7 +621,7 @@ describe("sessionCommand", () => {

const replacedMessages = (mockContext.replaceMessages as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(replacedMessages).toHaveLength(2)
expect(replacedMessages[1].content).toContain("Forking session from share ID")
expect(replacedMessages[1].content).toContain("Forking session from ID")
expect(replacedMessages[1].content).toContain("share-123")
})

Expand All @@ -633,7 +633,7 @@ describe("sessionCommand", () => {
expect(mockContext.addMessage).toHaveBeenCalledTimes(1)
const message = (mockContext.addMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(message.type).toBe("error")
expect(message.content).toContain("Usage: /session fork <shareId>")
expect(message.content).toContain("Usage: /session fork <id>")
expect(mockSessionManager.forkSession).not.toHaveBeenCalled()
})

Expand All @@ -645,7 +645,7 @@ describe("sessionCommand", () => {
expect(mockContext.addMessage).toHaveBeenCalledTimes(1)
const message = (mockContext.addMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(message.type).toBe("error")
expect(message.content).toContain("Usage: /session fork <shareId>")
expect(message.content).toContain("Usage: /session fork <id>")
})

it("should handle fork error gracefully", async () => {
Expand Down
16 changes: 8 additions & 8 deletions cli/src/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ async function shareSession(context: CommandContext): Promise<void> {
addMessage({
...generateMessage(),
type: "system",
content: `✅ Session shared successfully!\n\n\`https://kilo.ai/share/${result.share_id}\``,
content: `✅ Session shared successfully!\n\n\`https://app.kilo.ai/share/${result.share_id}\``,
})
} catch (error) {
addMessage({
Expand All @@ -212,15 +212,15 @@ async function shareSession(context: CommandContext): Promise<void> {
/**
* Fork a shared session by share ID
*/
async function forkSession(context: CommandContext, shareId: string): Promise<void> {
async function forkSession(context: CommandContext, id: string): Promise<void> {
const { addMessage, replaceMessages, refreshTerminal } = context
const sessionService = SessionManager.init()

if (!shareId) {
if (!id) {
addMessage({
...generateMessage(),
type: "error",
content: "Usage: /session fork <shareId>",
content: "Usage: /session fork <id>",
})
return
}
Expand All @@ -238,14 +238,14 @@ async function forkSession(context: CommandContext, shareId: string): Promise<vo
{
id: `system-${now + 1}`,
type: "system",
content: `Forking session from share ID \`${shareId}\`...`,
content: `Forking session from ID \`${id}\`...`,
ts: 2,
},
])

await refreshTerminal()

await sessionService.forkSession(shareId, true)
await sessionService.forkSession(id, true)

// Success message handled by restoreSession via extension messages
} catch (error) {
Expand Down Expand Up @@ -373,7 +373,7 @@ export const sessionCommand: Command = {
"/session search <query>",
"/session select <sessionId>",
"/session share",
"/session fork <shareId>",
"/session fork <id>",
"/session delete <sessionId>",
"/session rename <new name>",
],
Expand All @@ -390,7 +390,7 @@ export const sessionCommand: Command = {
{ value: "search", description: "Search sessions by title or ID" },
{ value: "select", description: "Restore a session" },
{ value: "share", description: "Share current session publicly" },
{ value: "fork", description: "Fork a shared session" },
{ value: "fork", description: "Fork a session" },
{ value: "delete", description: "Delete a session" },
{ value: "rename", description: "Rename the current session" },
],
Expand Down
2 changes: 1 addition & 1 deletion cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ program
.option("-pv, --provider <id>", "Select provider by ID (e.g., 'kilocode-1')")
.option("-mo, --model <model>", "Override model for the selected provider")
.option("-s, --session <sessionId>", "Restore a session by ID")
.option("-f, --fork <shareId>", "Fork a shared session by share ID")
.option("-f, --fork <shareId>", "Fork a session by ID")
.option("--nosplash", "Disable the welcome message and update notifications", false)
.argument("[prompt]", "The prompt or command to execute")
.action(async (prompt, options) => {
Expand Down
15 changes: 15 additions & 0 deletions src/activate/handleUri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ export const handleUri = async (uri: vscode.Uri) => {
})
break
}
case "/kilo/fork": {
const id = query.get("id")
if (id) {
await visibleProvider.postMessageToWebview({
type: "invoke",
invoke: "setChatBoxMessage",
text: `/session fork ${id}`,
})
await visibleProvider.postMessageToWebview({
type: "action",
action: "focusInput",
})
}
break
}
// kilocode_change end
case "/requesty": {
const code = query.get("code")
Expand Down
66 changes: 66 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { getSapAiCoreDeployments } from "../../api/providers/fetchers/sap-ai-cor
import { AutoPurgeScheduler } from "../../services/auto-purge" // kilocode_change
import { setPendingTodoList } from "../tools/UpdateTodoListTool"
import { ManagedIndexer } from "../../services/code-index/managed/ManagedIndexer"
import { SessionManager } from "../../shared/kilocode/cli-sessions/core/SessionManager" // kilocode_change

export const webviewMessageHandler = async (
provider: ClineProvider,
Expand Down Expand Up @@ -3842,6 +3843,71 @@ export const webviewMessageHandler = async (
}
break
}
case "sessionShare": {
try {
const sessionService = SessionManager.init()

if (!sessionService.sessionId) {
vscode.window.showErrorMessage("No active session. Start a new task to create a session.")
break
}

const result = await sessionService.shareSession()
const shareUrl = `https://app.kilo.ai/share/${result.share_id}`

// Copy URL to clipboard and show success notification
await vscode.env.clipboard.writeText(shareUrl)
vscode.window.showInformationMessage(`Session shared! Link copied to clipboard: ${shareUrl}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Failed to share session: ${errorMessage}`)
}
break
}
case "shareTaskSession": {
try {
if (!message.text) {
vscode.window.showErrorMessage("Task ID is required for sharing a task session")
break
}

const taskId = message.text
const sessionService = SessionManager.init()

const sessionId = await sessionService.getSessionFromTask(taskId, provider)

// Share the session
const result = await sessionService.shareSession(sessionId)
const shareUrl = `https://app.kilo.ai/share/${result.share_id}`

await vscode.env.clipboard.writeText(shareUrl)
vscode.window.showInformationMessage(`Session shared! Link copied to clipboard.`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Failed to share task session: ${errorMessage}`)
}
break
}
case "sessionFork": {
try {
if (!message.shareId) {
vscode.window.showErrorMessage("ID is required for forking a session")
break
}

const sessionService = SessionManager.init()

await sessionService.forkSession(message.shareId, true)

await provider.postStateToWebview()

vscode.window.showInformationMessage(`Session forked successfully from ${message.shareId}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Failed to fork session: ${errorMessage}`)
}
break
}
case "singleCompletion": {
try {
const { text, completionRequestId } = message
Expand Down
4 changes: 3 additions & 1 deletion src/services/kilo-session/ExtensionMessengerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import type { WebviewMessage } from "../../shared/kilocode/cli-sessions/types/IE
import type { ExtensionMessage } from "../../shared/ExtensionMessage"
import type { ClineProvider } from "../../core/webview/ClineProvider"
import { singleCompletionHandler } from "../../utils/single-completion-handler"
import { webviewMessageHandler } from "../../core/webview/webviewMessageHandler"

export class ExtensionMessengerImpl implements IExtensionMessenger {
constructor(private readonly provider: ClineProvider) {}

// we can directly handle whatever is sent
async sendWebviewMessage(message: WebviewMessage): Promise<void> {
await this.provider.postMessageToWebview(message as unknown as ExtensionMessage)
return webviewMessageHandler(this.provider, message)
}

async requestSingleCompletion(prompt: string, timeoutMs: number): Promise<string> {
Expand Down
28 changes: 7 additions & 21 deletions src/services/kilo-session/ExtensionPathProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,20 @@ export class ExtensionPathProvider implements IPathProvider {

constructor(context: vscode.ExtensionContext) {
this.globalStoragePath = context.globalStorageUri.fsPath
this.ensureDirectories()
}

private ensureDirectories(): void {
const sessionsDir = path.join(this.globalStoragePath, "sessions")
const tasksDir = this.getTasksDir()
const workspacesDir = path.join(sessionsDir, "workspaces")

for (const dir of [sessionsDir, tasksDir, workspacesDir]) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
}

getTasksDir(): string {
return path.join(this.globalStoragePath, "sessions", "tasks")
return path.join(this.globalStoragePath, "tasks")
}

getSessionFilePath(workspaceDir: string): string {
const hash = createHash("sha256").update(workspaceDir).digest("hex").substring(0, 16)
const workspacesDir = path.join(this.globalStoragePath, "sessions", "workspaces")
const workspaceSessionDir = path.join(workspacesDir, hash)
getSessionFilePath(workspaceName: string): string {
const hash = createHash("sha256").update(workspaceName).digest("hex").substring(0, 16)
const workspaceDir = path.join(this.globalStoragePath, "sessions", hash)

if (!existsSync(workspaceSessionDir)) {
mkdirSync(workspaceSessionDir, { recursive: true })
if (!existsSync(workspaceDir)) {
mkdirSync(workspaceDir, { recursive: true })
}

return path.join(workspaceSessionDir, "session.json")
return path.join(workspaceDir, "session.json")
}
}
4 changes: 4 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,13 @@ export interface WebviewMessage {
| "updateSettings"
| "requestManagedIndexerState" // kilocode_change
| "addTaskToHistory" // kilocode_change
| "sessionShare" // kilocode_change
| "shareTaskSession" // kilocode_change
| "sessionFork" // kilocode_change
| "singleCompletion" // kilocode_change
text?: string
completionRequestId?: string // kilocode_change
shareId?: string // kilocode_change - for sessionFork
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
disabled?: boolean
Expand Down
59 changes: 54 additions & 5 deletions src/shared/kilocode/cli-sessions/core/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createHash } from "crypto"
import type { IPathProvider } from "../types/IPathProvider.js"
import type { ILogger } from "../types/ILogger.js"
import type { IExtensionMessenger } from "../types/IExtensionMessenger.js"
import type { ITaskDataProvider } from "../types/ITaskDataProvider.js"
import { SessionClient } from "./SessionClient.js"
import { SessionWithSignedUrls, CliSessionSharedState } from "./SessionClient.js"
import type { ClineMessage, HistoryItem } from "@roo-code/types"
Expand Down Expand Up @@ -69,7 +70,7 @@ export class SessionManager {
private readonly pathProvider: IPathProvider
private readonly logger: ILogger
private readonly extensionMessenger: IExtensionMessenger
private readonly sessionPersistenceManager: SessionPersistenceManager
public readonly sessionPersistenceManager: SessionPersistenceManager
public readonly sessionClient: SessionClient
private readonly onSessionCreated: (message: SessionCreatedMessage) => void
private readonly onSessionRestored: () => void
Expand Down Expand Up @@ -287,14 +288,14 @@ export class SessionManager {
}
}

async shareSession() {
const sessionId = this.sessionId
if (!sessionId) {
async shareSession(sessionId?: string) {
const sessionIdToShare = sessionId || this.sessionId
if (!sessionIdToShare) {
throw new Error("No active session")
}

return await this.sessionClient.share({
session_id: sessionId,
session_id: sessionIdToShare,
shared_state: CliSessionSharedState.Public,
})
}
Expand Down Expand Up @@ -332,6 +333,50 @@ export class SessionManager {
await this.restoreSession(session_id, rethrowError)
}

async getSessionFromTask(taskId: string, provider: ITaskDataProvider): Promise<string> {
try {
let sessionId = this.sessionPersistenceManager.getSessionForTask(taskId)

if (!sessionId) {
this.logger.debug("No existing session for task, creating new session", "SessionManager", { taskId })

const { historyItem, apiConversationHistoryFilePath, uiMessagesFilePath } =
await provider.getTaskWithId(taskId)

const apiConversationHistory = JSON.parse(readFileSync(apiConversationHistoryFilePath, "utf8"))
const uiMessages = JSON.parse(readFileSync(uiMessagesFilePath, "utf8"))

const title = historyItem.task || this.getFirstMessageText(uiMessages, true) || ""

const session = await this.sessionClient.create({
title,
created_on_platform: process.env.KILO_PLATFORM || this.platform,
})

sessionId = session.session_id

this.logger.info("Created new session for task", "SessionManager", { taskId, sessionId })

await this.sessionClient.uploadBlob(sessionId, "api_conversation_history", apiConversationHistory)
await this.sessionClient.uploadBlob(sessionId, "ui_messages", uiMessages)

this.logger.debug("Uploaded conversation blobs to session", "SessionManager", { sessionId })

this.sessionPersistenceManager.setSessionForTask(taskId, sessionId)
} else {
this.logger.debug("Found existing session for task", "SessionManager", { taskId, sessionId })
}

return sessionId
} catch (error) {
this.logger.error("Failed to get or create session from task", "SessionManager", {
taskId,
error: error instanceof Error ? error.message : String(error),
})
throw error
}
}

async destroy() {
this.logger.debug("Destroying SessionManager", "SessionManager", {
sessionId: this.sessionId,
Expand Down Expand Up @@ -374,6 +419,10 @@ export class SessionManager {
}
}

if (process.env.KILO_DISABLE_SESSIONS) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RSO @catrielmuller @pandemicsyn I noticed integration tests are creating sessions - once this is merged, they should probably set this env var.

return
}

this.isSyncing = true

try {
Expand Down
Loading