mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge pull request #11741 from Kilo-Org/feat/agent-manager-branch-naming
feat(agent-manager): generate task-focused branch names
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Automatically give Agent Manager branches task-focused names once the conversation establishes a clear workstream, with optional prefixes and a setting to disable automatic naming.
|
||||
@@ -924,6 +924,18 @@
|
||||
"default": false,
|
||||
"description": "Enable chat textarea autocomplete"
|
||||
},
|
||||
"kilo-code.new.agentManager.autoBranchNaming": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"scope": "application",
|
||||
"description": "Automatically name new Agent Manager branches when the conversation describes a clear task. Explicitly named and published branches are never renamed."
|
||||
},
|
||||
"kilo-code.new.agentManager.branchPrefix": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"scope": "application",
|
||||
"description": "Prefix for automatically named Agent Manager branches, for example 'marius/' or 'feature/'. Explicit branch names are unchanged."
|
||||
},
|
||||
"kilo-code.new.indexing.showButtonWhenDisabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type Work
|
||||
import { PRStatusBridge } from "./pr-status-bridge"
|
||||
import { GitOps } from "./GitOps"
|
||||
import { versionedName } from "./branch-name"
|
||||
import { BranchNamingController } from "./branch-naming"
|
||||
import { classifyWorktreeError } from "./git-import"
|
||||
import { SetupScriptService } from "./SetupScriptService"
|
||||
import { SetupScriptRunner } from "./SetupScriptRunner"
|
||||
@@ -71,6 +72,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
private prBridge!: PRStatusBridge
|
||||
private gitOps: GitOps
|
||||
private diffs: WorktreeDiffController
|
||||
private naming: BranchNamingController
|
||||
private staleWorktreeIds = new Set<string>()
|
||||
private toolRequests = new Set<string>()
|
||||
private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined
|
||||
@@ -128,6 +130,14 @@ export class AgentManagerProvider implements Disposable {
|
||||
})
|
||||
const semaphore = new Semaphore(3)
|
||||
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore })
|
||||
this.naming = new BranchNamingController({
|
||||
state: () => this.getStateManager(),
|
||||
manager: () => this.getWorktreeManager(),
|
||||
client: (dir) => this.connectionService.getClientAsync(dir),
|
||||
settings: () => this.host.autoBranchNaming(),
|
||||
push: () => this.pushState(),
|
||||
log: (msg) => this.log(msg),
|
||||
})
|
||||
const local = createLocalDiff(this.gitOps, (...args) => this.log(...args))
|
||||
this.diffs = new WorktreeDiffController({
|
||||
getState: () => this.getStateManager(),
|
||||
@@ -346,6 +356,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
msg = await this.contextMessage(msg)
|
||||
const m = msg as unknown as AgentManagerInMessage
|
||||
if (this.shouldWaitForState(m)) await this.waitForStateReady(m.type)
|
||||
this.onBranchPrompt(m)
|
||||
|
||||
const worktree = await this.onWorktreeMessage(m)
|
||||
if (worktree !== undefined) return worktree
|
||||
@@ -366,6 +377,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
return msg
|
||||
}
|
||||
|
||||
private onBranchPrompt(m: AgentManagerInMessage): void {
|
||||
if (m.type !== "sendMessage" && m.type !== "sendCommand") return
|
||||
const sessionID = m.sessionID ?? m.draftID ?? this.activeSessionId
|
||||
if (!sessionID) return
|
||||
const text = m.type === "sendMessage" ? m.text.trim() : `/${m.command} ${m.arguments}`.trim()
|
||||
if (!text) return
|
||||
this.naming.prompt({ sessionID, text, providerID: m.providerID, modelID: m.modelID })
|
||||
}
|
||||
|
||||
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
|
||||
@@ -783,6 +803,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
remote: result.remote,
|
||||
groupId: opts?.groupId,
|
||||
label: opts?.label,
|
||||
branchOwned: !opts?.existingBranch,
|
||||
})
|
||||
|
||||
// Push state immediately so the sidebar shows the new worktree with a loading indicator
|
||||
@@ -1010,6 +1031,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
const state = this.getStateManager()!
|
||||
state.addSession(session.id, created.worktree.id)
|
||||
if (!branchName && this.host.autoBranchNaming().enabled) state.armAutoName(created.worktree.id, session.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.
|
||||
@@ -1047,12 +1069,13 @@ export class AgentManagerProvider implements Disposable {
|
||||
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.
|
||||
const branch = worktree.branchOwned === false ? undefined : (worktree.originalBranch ?? worktree.branch)
|
||||
try {
|
||||
await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch)
|
||||
await manager.removeWorktree(worktree.path, branch)
|
||||
} catch (error) {
|
||||
this.log(`Failed to remove worktree from disk: ${error}`)
|
||||
}
|
||||
this.log(`Deleted worktree ${worktreeId} (${worktree.originalBranch ?? worktree.branch})`)
|
||||
this.log(`Deleted worktree ${worktreeId}${branch ? ` (${branch})` : ""}`)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1318,6 +1341,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
const state = this.getStateManager()!
|
||||
state.addSession(session.id, wt.worktree.id)
|
||||
if (!branchName && !worktreeName && this.host.autoBranchNaming().enabled) {
|
||||
state.armAutoName(wt.worktree.id, session.id)
|
||||
}
|
||||
|
||||
// Sandbox must match the user's choice before this session is exposed or
|
||||
// receives its initial prompt. A failed reconciliation aborts this version.
|
||||
@@ -1398,6 +1424,12 @@ export class AgentManagerProvider implements Disposable {
|
||||
const msg = messages[i]!
|
||||
if (text) {
|
||||
this.log(`Sending initial message to version ${i + 1} (session=${msg.sessionId})`)
|
||||
this.naming.prompt({
|
||||
sessionID: msg.sessionId,
|
||||
text,
|
||||
providerID: msg.providerID,
|
||||
modelID: msg.modelID,
|
||||
})
|
||||
}
|
||||
this.postToWebview({ type: "agentManager.sendInitialMessage", ...msg })
|
||||
if (text && i < messages.length - 1) {
|
||||
@@ -1877,6 +1909,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.connectionService.unregisterFocused("agent-manager")
|
||||
this.connectionService.registerOpen("agent-manager", [])
|
||||
this.diffs.stop()
|
||||
this.naming.dispose()
|
||||
this.statsPoller.stop()
|
||||
this.gitOps.dispose()
|
||||
this.prBridge.poller.stop()
|
||||
|
||||
@@ -153,6 +153,11 @@ export class WorktreeManager {
|
||||
return this.withGitLock(() => this.createWorktreeImpl(params))
|
||||
}
|
||||
|
||||
async renameBranch(worktreePath: string, current: string, requested: string): Promise<string> {
|
||||
await this.ensureMigrated()
|
||||
return this.withGitLock(() => this.renameBranchImpl(worktreePath, current, requested))
|
||||
}
|
||||
|
||||
private async ensureGitAvailable(): Promise<void> {
|
||||
try {
|
||||
await execWithShellEnv("git", ["--version"])
|
||||
@@ -272,6 +277,38 @@ export class WorktreeManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async renameBranchImpl(worktreePath: string, current: string, requested: string): Promise<string> {
|
||||
if (!this.isManagedPath(worktreePath)) throw new Error("Worktree is not managed by Agent Manager")
|
||||
|
||||
const git = simpleGit(worktreePath)
|
||||
const actual = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
|
||||
if (actual === "HEAD" || actual !== current) throw new Error("Branch changed before automatic naming")
|
||||
|
||||
const upstream = (
|
||||
await this.git.raw(["for-each-ref", "--format=%(upstream:short)", `refs/heads/${current}`])
|
||||
).trim()
|
||||
if (upstream) throw new Error("Branch already has an upstream")
|
||||
|
||||
const remotes = (await this.git.raw(["for-each-ref", "--format=%(refname:short)", "refs/remotes"])).split("\n")
|
||||
if (remotes.some((ref) => ref.endsWith(`/${current}`))) throw new Error("Branch already exists on a remote")
|
||||
|
||||
const base = requested.trim()
|
||||
if (!base || base === current) throw new Error("Generated branch name is unchanged")
|
||||
await this.git.raw(["check-ref-format", "--branch", base])
|
||||
|
||||
const locals = new Set((await this.git.branch()).all)
|
||||
const remoteNames = new Set(remotes.filter(Boolean).map((ref) => ref.replace(/^[^/]+\//, "")))
|
||||
const available = (name: string) => !locals.has(name) && !remoteNames.has(name)
|
||||
const branch = available(base)
|
||||
? base
|
||||
: Array.from({ length: 10_000 }, (_, index) => `${base}-${index + 2}`).find(available)
|
||||
if (!branch) throw new Error("No available generated branch name")
|
||||
|
||||
await git.raw(["branch", "-m", current, branch])
|
||||
this.log(`Renamed branch: ${current} -> ${branch}`)
|
||||
return branch
|
||||
}
|
||||
|
||||
private async prepareWorktreePath(worktreePath: string, reuse: boolean): Promise<void> {
|
||||
if (!fs.existsSync(worktreePath)) return
|
||||
if (!reuse) throw new Error(`Worktree path already exists: ${worktreePath}`)
|
||||
|
||||
@@ -32,9 +32,12 @@ export interface Worktree {
|
||||
prUrl?: string
|
||||
/** Cached PR state for correct badge color on reload (open/merged/closed/draft). */
|
||||
prState?: string
|
||||
/** Original branch created with the worktree, used for cleanup on deletion.
|
||||
* Set automatically when `branch` is updated via live sync. */
|
||||
/** Original branch created with the worktree, used for cleanup after a manual branch change. */
|
||||
originalBranch?: string
|
||||
/** Whether Agent Manager created and may safely clean up this branch. Undefined preserves legacy behavior. */
|
||||
branchOwned?: boolean
|
||||
/** Initial session whose prompts may name this placeholder branch once. */
|
||||
autoNameSessionId?: string
|
||||
/** Section this worktree belongs to, or undefined for ungrouped. */
|
||||
sectionId?: string
|
||||
}
|
||||
@@ -176,6 +179,7 @@ export class WorktreeStateManager {
|
||||
remote?: string
|
||||
groupId?: string
|
||||
label?: string
|
||||
branchOwned?: boolean
|
||||
}): Worktree {
|
||||
const id = generateId("wt")
|
||||
const wt: Worktree = {
|
||||
@@ -188,6 +192,7 @@ export class WorktreeStateManager {
|
||||
if (params.remote) wt.remote = params.remote
|
||||
if (params.groupId) wt.groupId = params.groupId
|
||||
if (params.label) wt.label = params.label
|
||||
if (params.branchOwned !== undefined) wt.branchOwned = params.branchOwned
|
||||
this.worktrees.set(id, wt)
|
||||
this.setNormalizedWorktreeOrder(this.worktreeOrder)
|
||||
this.log(
|
||||
@@ -225,9 +230,35 @@ export class WorktreeStateManager {
|
||||
updateWorktreeBranch(id: string, branch: string): boolean {
|
||||
const wt = this.worktrees.get(id)
|
||||
if (!wt || wt.branch === branch) return false
|
||||
if (!wt.originalBranch) wt.originalBranch = wt.branch
|
||||
if (!wt.originalBranch && wt.branchOwned !== false) wt.originalBranch = wt.branch
|
||||
this.log(`Updated worktree ${id} branch: ${wt.branch} → ${branch}`)
|
||||
wt.branch = branch
|
||||
wt.autoNameSessionId = undefined
|
||||
void this.save()
|
||||
return true
|
||||
}
|
||||
|
||||
armAutoName(id: string, sessionId: string): void {
|
||||
const wt = this.worktrees.get(id)
|
||||
if (!wt || wt.branchOwned !== true) return
|
||||
wt.autoNameSessionId = sessionId
|
||||
void this.save()
|
||||
}
|
||||
|
||||
clearAutoName(id: string): void {
|
||||
const wt = this.worktrees.get(id)
|
||||
if (!wt?.autoNameSessionId) return
|
||||
wt.autoNameSessionId = undefined
|
||||
void this.save()
|
||||
}
|
||||
|
||||
renameOwnedBranch(id: string, current: string, branch: string): boolean {
|
||||
const wt = this.worktrees.get(id)
|
||||
if (!wt || wt.branch !== current || wt.branchOwned !== true) return false
|
||||
wt.branch = branch
|
||||
wt.originalBranch = undefined
|
||||
wt.autoNameSessionId = undefined
|
||||
this.log(`Automatically renamed worktree ${id} branch: ${current} → ${branch}`)
|
||||
void this.save()
|
||||
return true
|
||||
}
|
||||
@@ -276,6 +307,10 @@ export class WorktreeStateManager {
|
||||
addSession(sessionId: string, worktreeId: string | null): ManagedSession {
|
||||
const session: ManagedSession = { id: sessionId, worktreeId, createdAt: new Date().toISOString() }
|
||||
this.sessions.set(sessionId, session)
|
||||
const worktree = worktreeId ? this.worktrees.get(worktreeId) : undefined
|
||||
if (worktree?.autoNameSessionId && worktreeId && this.getSessions(worktreeId).length > 1) {
|
||||
worktree.autoNameSessionId = undefined
|
||||
}
|
||||
this.log(`Added session ${sessionId} to worktree ${worktreeId ?? "local"}`)
|
||||
void this.save()
|
||||
return session
|
||||
@@ -285,7 +320,11 @@ export class WorktreeStateManager {
|
||||
moveSession(sessionId: string, worktreeId: string | null): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
const previous = session.worktreeId ? this.worktrees.get(session.worktreeId) : undefined
|
||||
if (previous?.autoNameSessionId === sessionId) previous.autoNameSessionId = undefined
|
||||
session.worktreeId = worktreeId
|
||||
const worktree = worktreeId ? this.worktrees.get(worktreeId) : undefined
|
||||
if (worktree?.autoNameSessionId) worktree.autoNameSessionId = undefined
|
||||
this.log(`Moved session ${sessionId} to ${worktreeId ?? "local"}`)
|
||||
void this.save()
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ function createMockHost(): Host {
|
||||
return {
|
||||
openPanel: vi.fn(),
|
||||
workspacePath: () => "/repo",
|
||||
autoBranchNaming: () => ({ enabled: true, prefix: "" }),
|
||||
showError: vi.fn(),
|
||||
openDocument: vi.fn().mockResolvedValue(undefined),
|
||||
openFile: vi.fn(),
|
||||
@@ -100,6 +101,8 @@ function createHarness() {
|
||||
panel: { sessions: { registerSession: ReturnType<typeof vi.fn> } } | undefined
|
||||
prBridge: { handleMessage: ReturnType<typeof vi.fn> }
|
||||
activeSessionId: string | undefined
|
||||
naming: { prompt: ReturnType<typeof vi.fn> }
|
||||
terminalRouter: { handle: ReturnType<typeof vi.fn> }
|
||||
stateReady: Promise<void> | undefined
|
||||
contextTarget: ReturnType<typeof vi.fn>
|
||||
createWorktreeOnDisk: ReturnType<typeof vi.fn>
|
||||
@@ -109,7 +112,7 @@ function createHarness() {
|
||||
registerWorktreeSession: ReturnType<typeof vi.fn>
|
||||
notifyWorktreeReady: ReturnType<typeof vi.fn>
|
||||
log: ReturnType<typeof vi.fn>
|
||||
onCreateWorktree: () => Promise<null>
|
||||
onCreateWorktree: (baseBranch?: string, branchName?: string) => Promise<null>
|
||||
onMessage: (msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>
|
||||
}
|
||||
|
||||
@@ -121,12 +124,14 @@ function createHarness() {
|
||||
}
|
||||
manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) }
|
||||
manager.activeSessionId = undefined
|
||||
manager.naming = { prompt: vi.fn() }
|
||||
manager.terminalRouter = { handle: vi.fn().mockReturnValue(false) }
|
||||
manager.stateReady = Promise.resolve()
|
||||
manager.contextTarget = vi.fn()
|
||||
manager.createWorktreeOnDisk = vi.fn()
|
||||
manager.runSetupScriptForWorktree = vi.fn().mockResolvedValue(undefined)
|
||||
manager.createSessionInWorktree = vi.fn()
|
||||
manager.getStateManager = vi.fn().mockReturnValue({ addSession: vi.fn() })
|
||||
manager.getStateManager = vi.fn().mockReturnValue({ addSession: vi.fn(), armAutoName: vi.fn() })
|
||||
manager.registerWorktreeSession = vi.fn()
|
||||
manager.notifyWorktreeReady = vi.fn()
|
||||
manager.log = vi.fn()
|
||||
@@ -142,7 +147,7 @@ describe("AgentManagerProvider worktree creation", () => {
|
||||
result: { path: "/repo/.kilo/worktrees/wt-1", branch: "feature/wt-1", parentBranch: "main" },
|
||||
}
|
||||
const session = { id: "session-1" }
|
||||
const state = { addSession: vi.fn() }
|
||||
const state = { addSession: vi.fn(), armAutoName: vi.fn() }
|
||||
|
||||
manager.createWorktreeOnDisk.mockResolvedValue(created)
|
||||
manager.createSessionInWorktree.mockResolvedValue(session)
|
||||
@@ -151,9 +156,25 @@ describe("AgentManagerProvider worktree creation", () => {
|
||||
await manager.onCreateWorktree()
|
||||
|
||||
expect(state.addSession).toHaveBeenCalledWith("session-1", "wt-1")
|
||||
expect(state.armAutoName).toHaveBeenCalledWith("wt-1", "session-1")
|
||||
expect(manager.panel!.sessions.registerSession).toHaveBeenCalledWith(session)
|
||||
})
|
||||
|
||||
it("does not arm automatic naming for a custom branch", async () => {
|
||||
const manager = createHarness()
|
||||
const state = { addSession: vi.fn(), armAutoName: vi.fn() }
|
||||
manager.createWorktreeOnDisk.mockResolvedValue({
|
||||
worktree: { id: "wt-1" },
|
||||
result: { path: "/repo/.kilo/worktrees/custom", branch: "my-custom-branch", parentBranch: "main" },
|
||||
})
|
||||
manager.createSessionInWorktree.mockResolvedValue({ id: "session-1" })
|
||||
manager.getStateManager.mockReturnValue(state)
|
||||
|
||||
await manager.onCreateWorktree(undefined, "my-custom-branch")
|
||||
|
||||
expect(state.armAutoName).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Regression for #8983: notifyWorktreeReady must push agentManager.state before
|
||||
// registerSession posts sessionCreated. Reverse order makes the webview route the
|
||||
// new worktree session into the Local tab.
|
||||
@@ -164,7 +185,7 @@ describe("AgentManagerProvider worktree creation", () => {
|
||||
result: { path: "/repo/.kilo/worktrees/wt-1", branch: "feature/wt-1", parentBranch: "main" },
|
||||
})
|
||||
manager.createSessionInWorktree.mockResolvedValue({ id: "session-1" })
|
||||
manager.getStateManager.mockReturnValue({ addSession: vi.fn() })
|
||||
manager.getStateManager.mockReturnValue({ addSession: vi.fn(), armAutoName: vi.fn() })
|
||||
|
||||
await manager.onCreateWorktree()
|
||||
|
||||
@@ -183,7 +204,7 @@ describe("AgentManagerProvider worktree creation", () => {
|
||||
result: { path: "/repo/.kilo/worktrees/wt-2", branch: "feature/wt-2", parentBranch: "main" },
|
||||
})
|
||||
manager.createSessionInWorktree.mockResolvedValue({ id: "session-2" })
|
||||
manager.getStateManager.mockReturnValue({ addSession: vi.fn() })
|
||||
manager.getStateManager.mockReturnValue({ addSession: vi.fn(), armAutoName: vi.fn() })
|
||||
|
||||
const pending = manager.onCreateWorktree()
|
||||
await Promise.resolve()
|
||||
@@ -227,6 +248,28 @@ describe("AgentManagerProvider worktree creation", () => {
|
||||
draftID: "draft-1",
|
||||
contextDirectory: "/repo/.kilo/worktrees/wt-1",
|
||||
})
|
||||
expect(manager.naming.prompt).toHaveBeenCalledWith({
|
||||
sessionID: "draft-1",
|
||||
text: "continue",
|
||||
providerID: undefined,
|
||||
modelID: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("retries branch naming when the user answers a clarification", async () => {
|
||||
const manager = createHarness()
|
||||
manager.activeSessionId = "session-wt"
|
||||
|
||||
await manager.onMessage({
|
||||
type: "questionReply",
|
||||
requestID: "question-1",
|
||||
answers: [["Node.js", "Use JWT"]],
|
||||
})
|
||||
|
||||
expect(manager.naming.prompt).toHaveBeenCalledWith({
|
||||
sessionID: "session-wt",
|
||||
text: "Node.js\nUse JWT",
|
||||
})
|
||||
})
|
||||
|
||||
it.each([{ type: "requestSandboxDefault" }, { type: "setSandboxDefault", enabled: false, requestID: "request-1" }])(
|
||||
|
||||
@@ -16,6 +16,16 @@ export function sanitizeBranchName(name: string, maxLength = 50): string {
|
||||
.replace(/-+/g, "-")
|
||||
}
|
||||
|
||||
export function semanticBranchName(title: string, prefix = "", maxLength = 50): string {
|
||||
const parts = prefix
|
||||
.split("/")
|
||||
.map((part) => sanitizeBranchName(part))
|
||||
.filter(Boolean)
|
||||
const head = parts.length > 0 ? `${parts.join("/")}/` : ""
|
||||
const slug = sanitizeBranchName(title, Math.max(0, maxLength - head.length))
|
||||
return slug ? `${head}${slug}` : ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a natural two-word branch name (e.g. "ambitious-keyboard") using
|
||||
* the friendly-words package. Checks `existingBranches` to avoid collisions,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { semanticBranchName } from "./branch-name"
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
|
||||
interface Prompt {
|
||||
sessionID: string
|
||||
text: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
interface Client {
|
||||
branchName: {
|
||||
generate: (
|
||||
parameters: {
|
||||
directory: string
|
||||
sessionID: string
|
||||
prompt: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
},
|
||||
options: { throwOnError: true; signal: AbortSignal },
|
||||
) => Promise<{ data: { branch: string | null } }>
|
||||
}
|
||||
}
|
||||
|
||||
interface Manager {
|
||||
renameBranch: (path: string, current: string, branch: string) => Promise<string>
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
state: () => WorktreeStateManager | undefined
|
||||
manager: () => Manager | undefined
|
||||
client: (dir: string) => Promise<Client>
|
||||
settings: () => { enabled: boolean; prefix: string }
|
||||
push: () => void
|
||||
log: (msg: string) => void
|
||||
}
|
||||
|
||||
export class BranchNamingController {
|
||||
private readonly requests = new Map<string, AbortController>()
|
||||
|
||||
constructor(private readonly deps: Deps) {}
|
||||
|
||||
prompt(input: Prompt): void {
|
||||
const state = this.deps.state()
|
||||
const session = state?.getSession(input.sessionID)
|
||||
const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined
|
||||
if (!state || !worktree || worktree.autoNameSessionId !== input.sessionID) return
|
||||
if (!this.deps.settings().enabled) {
|
||||
state.clearAutoName(worktree.id)
|
||||
return
|
||||
}
|
||||
if (state.getSessions(worktree.id).length !== 1 || worktree.prNumber || worktree.prUrl) {
|
||||
state.clearAutoName(worktree.id)
|
||||
return
|
||||
}
|
||||
if (this.requests.has(worktree.id)) return
|
||||
|
||||
const request = new AbortController()
|
||||
this.requests.set(worktree.id, request)
|
||||
void this.generate(worktree.id, input, request)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const request of this.requests.values()) request.abort()
|
||||
this.requests.clear()
|
||||
}
|
||||
|
||||
private async generate(id: string, input: Prompt, request: AbortController): Promise<void> {
|
||||
const initial = this.deps.state()?.getWorktree(id)
|
||||
if (!initial) return
|
||||
|
||||
try {
|
||||
const client = await this.deps.client(initial.path)
|
||||
const { data } = await client.branchName.generate(
|
||||
{
|
||||
directory: initial.path,
|
||||
sessionID: input.sessionID,
|
||||
prompt: input.text,
|
||||
providerID: input.providerID,
|
||||
modelID: input.modelID,
|
||||
},
|
||||
{ throwOnError: true, signal: request.signal },
|
||||
)
|
||||
if (!data.branch || request.signal.aborted) return
|
||||
await this.rename(id, input.sessionID, data.branch)
|
||||
} catch (error) {
|
||||
if (request.signal.aborted) return
|
||||
this.deps.log(`Skipped automatic branch naming: ${error}`)
|
||||
} finally {
|
||||
if (this.requests.get(id) === request) this.requests.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private async rename(id: string, sessionID: string, generated: string): Promise<void> {
|
||||
const state = this.deps.state()
|
||||
const manager = this.deps.manager()
|
||||
const worktree = state?.getWorktree(id)
|
||||
const cfg = this.deps.settings()
|
||||
if (!state || !manager || !worktree || !cfg.enabled) return
|
||||
if (worktree.autoNameSessionId !== sessionID || worktree.branchOwned !== true) return
|
||||
if (state.getSessions(id).length !== 1 || worktree.prNumber || worktree.prUrl) return
|
||||
|
||||
const branch = semanticBranchName(generated, cfg.prefix)
|
||||
if (!branch) return
|
||||
const current = worktree.branch
|
||||
const renamed = await manager.renameBranch(worktree.path, current, branch)
|
||||
if (!state.renameOwnedBranch(id, current, renamed)) return
|
||||
this.deps.push()
|
||||
this.deps.log(`Automatically named branch from session ${sessionID}: ${renamed}`)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,9 @@ export interface Host {
|
||||
/** Get the workspace/project root path. */
|
||||
workspacePath(): string | undefined
|
||||
|
||||
/** Read the user's automatic branch naming preferences. */
|
||||
autoBranchNaming(): { enabled: boolean; prefix: string }
|
||||
|
||||
/** Show an error notification. */
|
||||
showError(msg: string): void
|
||||
|
||||
|
||||
@@ -641,6 +641,13 @@ interface SendCommandIn {
|
||||
contextDirectory?: string
|
||||
}
|
||||
|
||||
interface QuestionReplyIn {
|
||||
type: "questionReply"
|
||||
requestID: string
|
||||
sessionID?: string
|
||||
answers: string[][]
|
||||
}
|
||||
|
||||
interface RequestSandboxDefaultIn {
|
||||
type: "requestSandboxDefault"
|
||||
requestID?: string
|
||||
@@ -809,6 +816,7 @@ export type AgentManagerInMessage =
|
||||
| LoadMessagesIn
|
||||
| SendMessageIn
|
||||
| SendCommandIn
|
||||
| QuestionReplyIn
|
||||
| RequestSandboxDefaultIn
|
||||
| SetSandboxDefaultIn
|
||||
| ToggleSandboxIn
|
||||
|
||||
@@ -167,6 +167,14 @@ export class VscodeHost implements Host {
|
||||
return getWorkspaceRoot()
|
||||
}
|
||||
|
||||
autoBranchNaming(): { enabled: boolean; prefix: string } {
|
||||
const cfg = vscode.workspace.getConfiguration("kilo-code.new.agentManager")
|
||||
return {
|
||||
enabled: cfg.get("autoBranchNaming", true),
|
||||
prefix: cfg.get("branchPrefix", ""),
|
||||
}
|
||||
}
|
||||
|
||||
showError(msg: string): void {
|
||||
void vscode.window.showErrorMessage(msg)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ export class WorktreeImporter {
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
remote: result.remote,
|
||||
branchOwned: false,
|
||||
})
|
||||
this.host.push()
|
||||
|
||||
@@ -149,6 +150,7 @@ export class WorktreeImporter {
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
remote: result.remote,
|
||||
branchOwned: false,
|
||||
})
|
||||
this.host.push()
|
||||
|
||||
@@ -211,7 +213,7 @@ export class WorktreeImporter {
|
||||
}
|
||||
|
||||
const base = await manager.resolveBaseBranch()
|
||||
worktree = state.addWorktree({ branch, path, parentBranch: base.branch, remote: base.remote })
|
||||
worktree = state.addWorktree({ branch, path, parentBranch: base.branch, remote: base.remote, branchOwned: false })
|
||||
this.host.push()
|
||||
|
||||
const session = await this.host.session(path, branch, worktree.id)
|
||||
@@ -287,6 +289,7 @@ export class WorktreeImporter {
|
||||
path: external.path,
|
||||
parentBranch: base.branch,
|
||||
remote: base.remote,
|
||||
branchOwned: false,
|
||||
})
|
||||
const session = await this.host.session(external.path, external.branch, worktree.id)
|
||||
if (session) {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import { BranchNamingController } from "../../src/agent-manager/branch-naming"
|
||||
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
function deferred<T>() {
|
||||
const result = Promise.withResolvers<T>()
|
||||
return result
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
|
||||
describe("BranchNamingController", () => {
|
||||
let root: string
|
||||
let state: WorktreeStateManager
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "branch-naming-test-"))
|
||||
fs.mkdirSync(path.join(root, ".kilo"), { recursive: true })
|
||||
state = new WorktreeStateManager(root, () => {})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await state.flush()
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("retries on a later message when the first attempt is not clear yet", async () => {
|
||||
const wt = state.addWorktree({
|
||||
branch: "quiet-river",
|
||||
path: "/tmp/quiet-river",
|
||||
parentBranch: "main",
|
||||
branchOwned: true,
|
||||
})
|
||||
state.addSession("session-1", wt.id)
|
||||
state.armAutoName(wt.id, "session-1")
|
||||
const renamed: string[] = []
|
||||
let requests = 0
|
||||
const naming = new BranchNamingController({
|
||||
state: () => state,
|
||||
manager: () => ({
|
||||
renameBranch: async (_path, _current, branch) => {
|
||||
renamed.push(branch)
|
||||
return branch
|
||||
},
|
||||
}),
|
||||
client: async () => ({
|
||||
branchName: {
|
||||
generate: async () => {
|
||||
requests += 1
|
||||
return { data: { branch: requests === 1 ? null : "fix-final-task" } }
|
||||
},
|
||||
},
|
||||
}),
|
||||
settings: () => ({ enabled: true, prefix: "" }),
|
||||
push: () => {},
|
||||
log: () => {},
|
||||
})
|
||||
|
||||
naming.prompt({ sessionID: "session-1", text: "hi" })
|
||||
await settle()
|
||||
expect(state.getWorktree(wt.id)?.autoNameSessionId).toBe("session-1")
|
||||
naming.prompt({ sessionID: "session-1", text: "Fix the task" })
|
||||
await settle()
|
||||
|
||||
expect(requests).toBe(2)
|
||||
expect(renamed).toEqual(["fix-final-task"])
|
||||
expect(state.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it("renames once and applies the user prefix", async () => {
|
||||
const wt = state.addWorktree({
|
||||
branch: "quiet-river",
|
||||
path: "/tmp/quiet-river",
|
||||
parentBranch: "main",
|
||||
branchOwned: true,
|
||||
})
|
||||
state.addSession("session-1", wt.id)
|
||||
state.armAutoName(wt.id, "session-1")
|
||||
const prompts: string[] = []
|
||||
const naming = new BranchNamingController({
|
||||
state: () => state,
|
||||
manager: () => ({ renameBranch: async (_path, _current, branch) => branch }),
|
||||
client: async () => ({
|
||||
branchName: {
|
||||
generate: async (input) => {
|
||||
prompts.push(input.prompt)
|
||||
return { data: { branch: "fix-token-refresh-race" } }
|
||||
},
|
||||
},
|
||||
}),
|
||||
settings: () => ({ enabled: true, prefix: "Marius / Features" }),
|
||||
push: () => {},
|
||||
log: () => {},
|
||||
})
|
||||
|
||||
naming.prompt({ sessionID: "session-1", text: "Fix the token refresh race" })
|
||||
await settle()
|
||||
|
||||
expect(prompts).toEqual(["Fix the token refresh race"])
|
||||
expect(state.getWorktree(wt.id)).toMatchObject({
|
||||
branch: "marius/features/fix-token-refresh-race",
|
||||
autoNameSessionId: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not touch an explicitly named branch", async () => {
|
||||
const wt = state.addWorktree({
|
||||
branch: "my-custom-branch",
|
||||
path: "/tmp/custom",
|
||||
parentBranch: "main",
|
||||
branchOwned: true,
|
||||
})
|
||||
state.addSession("session-1", wt.id)
|
||||
let requests = 0
|
||||
const naming = new BranchNamingController({
|
||||
state: () => state,
|
||||
manager: () => ({ renameBranch: async (_path, _current, branch) => branch }),
|
||||
client: async () => ({
|
||||
branchName: {
|
||||
generate: async () => {
|
||||
requests += 1
|
||||
return { data: { branch: "replace-custom-name" } }
|
||||
},
|
||||
},
|
||||
}),
|
||||
settings: () => ({ enabled: true, prefix: "" }),
|
||||
push: () => {},
|
||||
log: () => {},
|
||||
})
|
||||
|
||||
naming.prompt({ sessionID: "session-1", text: "Implement auth" })
|
||||
await settle()
|
||||
|
||||
expect(requests).toBe(0)
|
||||
expect(state.getWorktree(wt.id)?.branch).toBe("my-custom-branch")
|
||||
})
|
||||
|
||||
it("does not start another request while naming is pending", async () => {
|
||||
const wt = state.addWorktree({
|
||||
branch: "quiet-river",
|
||||
path: "/tmp/quiet-river",
|
||||
parentBranch: "main",
|
||||
branchOwned: true,
|
||||
})
|
||||
state.addSession("session-1", wt.id)
|
||||
state.armAutoName(wt.id, "session-1")
|
||||
const first = deferred<{ data: { branch: string | null } }>()
|
||||
const renamed: string[] = []
|
||||
let requests = 0
|
||||
const naming = new BranchNamingController({
|
||||
state: () => state,
|
||||
manager: () => ({
|
||||
renameBranch: async (_path, _current, branch) => {
|
||||
renamed.push(branch)
|
||||
return branch
|
||||
},
|
||||
}),
|
||||
client: async () => ({
|
||||
branchName: {
|
||||
generate: async () => {
|
||||
requests += 1
|
||||
return first.promise
|
||||
},
|
||||
},
|
||||
}),
|
||||
settings: () => ({ enabled: true, prefix: "" }),
|
||||
push: () => {},
|
||||
log: () => {},
|
||||
})
|
||||
|
||||
naming.prompt({ sessionID: "session-1", text: "Explore some options" })
|
||||
await Promise.resolve()
|
||||
naming.prompt({ sessionID: "session-1", text: "Fix the final task" })
|
||||
await settle()
|
||||
first.resolve({ data: { branch: "explore-options" } })
|
||||
await settle()
|
||||
|
||||
expect(requests).toBe(1)
|
||||
expect(renamed).toEqual(["explore-options"])
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,12 @@ import path from "node:path"
|
||||
import fs from "node:fs/promises"
|
||||
import { existsSync } from "node:fs"
|
||||
import { WorktreeManager } from "../../src/agent-manager/WorktreeManager"
|
||||
import { generateBranchName, sanitizeBranchName, versionedName } from "../../src/agent-manager/branch-name"
|
||||
import {
|
||||
generateBranchName,
|
||||
sanitizeBranchName,
|
||||
semanticBranchName,
|
||||
versionedName,
|
||||
} from "../../src/agent-manager/branch-name"
|
||||
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
import simpleGit from "simple-git"
|
||||
|
||||
@@ -115,6 +120,24 @@ describe("generateBranchName", () => {
|
||||
// sanitizeBranchName
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("semanticBranchName", () => {
|
||||
it("creates a branch slug from a generated session title", () => {
|
||||
expect(semanticBranchName("Fix token refresh race")).toBe("fix-token-refresh-race")
|
||||
})
|
||||
|
||||
it("normalizes a user prefix and keeps branch separators", () => {
|
||||
expect(semanticBranchName("Add billing alerts", "marius/features/")).toBe("marius/features/add-billing-alerts")
|
||||
})
|
||||
|
||||
it("reserves the length limit for the prefix", () => {
|
||||
expect(semanticBranchName("a".repeat(100), "team/").length).toBeLessThanOrEqual(50)
|
||||
})
|
||||
|
||||
it("returns empty when the title has no usable characters", () => {
|
||||
expect(semanticBranchName("修复登录")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sanitizeBranchName", () => {
|
||||
it("replaces spaces with hyphens", () => {
|
||||
expect(sanitizeBranchName("model comparison")).toBe("model-comparison")
|
||||
@@ -718,6 +741,47 @@ describe("WorktreeManager.ensureGitExclude", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorktreeManager -- automatic branch rename
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("WorktreeManager.renameBranch", () => {
|
||||
it("renames a local-only branch without moving or cleaning the worktree", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
const created = await mgr.createWorktree({ branchName: "quiet-river" })
|
||||
await fs.writeFile(path.join(created.path, "draft.txt"), "keep me")
|
||||
|
||||
const branch = await mgr.renameBranch(created.path, created.branch, "fix-token-refresh")
|
||||
|
||||
expect(branch).toBe("fix-token-refresh")
|
||||
expect((await simpleGit(created.path).revparse(["--abbrev-ref", "HEAD"])).trim()).toBe(branch)
|
||||
expect(await fs.readFile(path.join(created.path, "draft.txt"), "utf-8")).toBe("keep me")
|
||||
expect((await simpleGit(root).branch()).all).not.toContain(created.branch)
|
||||
})
|
||||
|
||||
it("suffixes a generated name that already exists", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
const created = await mgr.createWorktree({ branchName: "quiet-river" })
|
||||
await simpleGit(root).branch(["fix-auth"])
|
||||
|
||||
expect(await mgr.renameBranch(created.path, created.branch, "fix-auth")).toBe("fix-auth-2")
|
||||
})
|
||||
|
||||
it("does not rename a branch that exists on a remote", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
const created = await mgr.createWorktree({ branchName: "quiet-river" })
|
||||
const hash = (await simpleGit(root).revparse(["HEAD"])).trim()
|
||||
await simpleGit(root).raw(["update-ref", `refs/remotes/origin/${created.branch}`, hash])
|
||||
|
||||
await expect(mgr.renameBranch(created.path, created.branch, "fix-auth")).rejects.toThrow(
|
||||
"already exists on a remote",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorktreeManager -- branch name collision retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -60,6 +60,67 @@ describe("WorktreeStateManager", () => {
|
||||
it("returns empty array when removing nonexistent worktree", () => {
|
||||
expect(manager.removeWorktree("nonexistent")).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("tracks one automatic rename without changing branch ownership", () => {
|
||||
const wt = manager.addWorktree({
|
||||
branch: "quiet-river",
|
||||
path: "/tmp/wt",
|
||||
parentBranch: "main",
|
||||
branchOwned: true,
|
||||
})
|
||||
manager.addSession("session-1", wt.id)
|
||||
manager.armAutoName(wt.id, "session-1")
|
||||
|
||||
expect(manager.getWorktree(wt.id)?.autoNameSessionId).toBe("session-1")
|
||||
expect(manager.renameOwnedBranch(wt.id, "quiet-river", "fix-token-refresh")).toBe(true)
|
||||
expect(manager.getWorktree(wt.id)).toMatchObject({
|
||||
branch: "fix-token-refresh",
|
||||
branchOwned: true,
|
||||
autoNameSessionId: undefined,
|
||||
originalBranch: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("treats an observed branch change as manual and cancels automatic naming", () => {
|
||||
const wt = manager.addWorktree({
|
||||
branch: "quiet-river",
|
||||
path: "/tmp/wt",
|
||||
parentBranch: "main",
|
||||
branchOwned: true,
|
||||
})
|
||||
manager.armAutoName(wt.id, "session-1")
|
||||
|
||||
expect(manager.updateWorktreeBranch(wt.id, "my-manual-name")).toBe(true)
|
||||
expect(manager.getWorktree(wt.id)).toMatchObject({
|
||||
branch: "my-manual-name",
|
||||
originalBranch: "quiet-river",
|
||||
autoNameSessionId: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("cancels automatic naming when a worktree gains another session", () => {
|
||||
const wt = manager.addWorktree({
|
||||
branch: "quiet-river",
|
||||
path: "/tmp/wt",
|
||||
parentBranch: "main",
|
||||
branchOwned: true,
|
||||
})
|
||||
manager.addSession("session-1", wt.id)
|
||||
manager.armAutoName(wt.id, "session-1")
|
||||
manager.addSession("session-2", wt.id)
|
||||
expect(manager.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it("never arms imported branches for automatic naming", () => {
|
||||
const wt = manager.addWorktree({
|
||||
branch: "existing-feature",
|
||||
path: "/tmp/wt",
|
||||
parentBranch: "main",
|
||||
branchOwned: false,
|
||||
})
|
||||
manager.armAutoName(wt.id, "session-1")
|
||||
expect(manager.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("session CRUD", () => {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { KiloLLM } from "@/kilocode/session/llm"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const LIMIT = 4
|
||||
const CHARS = 1_000
|
||||
const BRANCH_CHARS = 50
|
||||
|
||||
const PROMPT = `Generate a Git branch name for the coherent engineering workstream described by the user's messages.
|
||||
|
||||
Return exactly one line:
|
||||
- a lowercase kebab-case branch slug, or
|
||||
- null when there is not yet a clear, stable workstream
|
||||
|
||||
Return null for greetings, acknowledgements, capability questions, casual conversation, vague requests, unresolved brainstorming, or messages that only select an option without enough preceding context.
|
||||
A concrete implementation, investigation, planning, documentation, or research task is a valid workstream.
|
||||
Name the durable goal or outcome, not a tentative implementation detail. Prefer an action and object, such as fix-token-refresh-race or research-branch-naming.
|
||||
If the user asks for a specific branch name, prefer that name.
|
||||
Do not include a prefix, ticket number, explanation, quotes, markdown, or punctuation other than hyphens.`
|
||||
|
||||
function text(message: MessageV2.WithParts) {
|
||||
return message.parts
|
||||
.filter((part): part is MessageV2.TextPart => part.type === "text" && !part.synthetic && !part.ignored)
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function messages(history: MessageV2.WithParts[], latest: string) {
|
||||
const prior = history
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map(text)
|
||||
.filter(Boolean)
|
||||
const prompt = latest.trim()
|
||||
const all = normalize(prior.at(-1) ?? "") === normalize(prompt) || !prompt ? prior : [...prior, prompt]
|
||||
return all.slice(-LIMIT).map((message) => message.slice(0, CHARS))
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
export function parse(value: string) {
|
||||
const line = value
|
||||
.replace(/<think>[\s\S]*?<\/think>\s*/gi, "")
|
||||
.replace(/^```(?:json|text)?\s*/i, "")
|
||||
.replace(/```$/i, "")
|
||||
.trim()
|
||||
.split("\n")[0]
|
||||
?.trim()
|
||||
.replace(/^['"`]|['"`]$/g, "")
|
||||
.toLowerCase()
|
||||
if (!line || line === "null") return null
|
||||
return (
|
||||
line
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, BRANCH_CHARS)
|
||||
.replace(/-+$/g, "") || null
|
||||
)
|
||||
}
|
||||
|
||||
export const generate = Effect.fn("BranchName.generate")(function* (input: {
|
||||
sessionID: SessionID
|
||||
messages: string[]
|
||||
providerID?: ProviderID
|
||||
modelID?: ModelID
|
||||
}) {
|
||||
if (input.messages.length === 0) return null
|
||||
|
||||
const provider = yield* Provider.Service
|
||||
const llm = yield* LLM.Service
|
||||
const ref =
|
||||
input.providerID && input.modelID
|
||||
? { providerID: input.providerID, modelID: input.modelID }
|
||||
: yield* provider.defaultModel()
|
||||
const model =
|
||||
(yield* provider.getSmallModel(ref.providerID)) ?? (yield* provider.getModel(ref.providerID, ref.modelID))
|
||||
const agent: Agent.Info = {
|
||||
name: "branch-name",
|
||||
mode: "primary",
|
||||
hidden: true,
|
||||
options: {},
|
||||
permission: [],
|
||||
prompt: PROMPT,
|
||||
temperature: 0.1,
|
||||
}
|
||||
const user: MessageV2.User = {
|
||||
id: MessageID.ascending(),
|
||||
sessionID: input.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: agent.name,
|
||||
model: { providerID: model.providerID, modelID: model.id },
|
||||
}
|
||||
const body = input.messages.map((message, index) => `${index + 1}. ${message}`).join("\n\n")
|
||||
const result = yield* KiloLLM.text(
|
||||
llm.stream({
|
||||
agent,
|
||||
user,
|
||||
tools: {},
|
||||
model,
|
||||
small: true,
|
||||
messages: [{ role: "user", content: `User messages, oldest to newest:\n\n${body}` }],
|
||||
sessionID: `branch-name:${input.sessionID}`,
|
||||
system: [],
|
||||
retries: 1,
|
||||
}),
|
||||
)
|
||||
return parse(result)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export const BranchNamePaths = {
|
||||
generate: "/session/:sessionID/branch-name",
|
||||
} as const
|
||||
|
||||
export const BranchNamePayload = Schema.Struct({
|
||||
prompt: Schema.String,
|
||||
providerID: Schema.optional(ProviderID),
|
||||
modelID: Schema.optional(ModelID),
|
||||
})
|
||||
|
||||
const BranchNameResponse = Schema.Struct({
|
||||
branch: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
export const BranchNameApi = HttpApi.make("branch-name")
|
||||
.add(
|
||||
HttpApiGroup.make("branch-name")
|
||||
.add(
|
||||
HttpApiEndpoint.post("generate", BranchNamePaths.generate, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: BranchNamePayload,
|
||||
success: described(BranchNameResponse, "Generated branch name or null when the task is not clear yet"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "branchName.generate",
|
||||
summary: "Generate branch name",
|
||||
description: "Generate a task-focused branch name from the current conversation.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "branch-name",
|
||||
description: "Kilo branch name routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "kilo HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Kilo HttpApi surface.",
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { generate, messages } from "@/kilocode/branch-name"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
|
||||
import { Cause, Effect, Option } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { BranchNamePayload } from "../groups/branch-name"
|
||||
|
||||
const log = Log.create({ service: "branch-name" })
|
||||
|
||||
export const branchNameHandlers = HttpApiBuilder.group(InstanceHttpApi, "branch-name", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
const handle = Effect.fn("BranchNameHttpApi.generate")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof BranchNamePayload.Type
|
||||
}) {
|
||||
const branch = yield* Effect.gen(function* () {
|
||||
const history = yield* session.messages({ sessionID: ctx.params.sessionID })
|
||||
return yield* generate({
|
||||
sessionID: ctx.params.sessionID,
|
||||
messages: messages(history, ctx.payload.prompt),
|
||||
providerID: ctx.payload.providerID,
|
||||
modelID: ctx.payload.modelID,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.timeoutOption("10 seconds"),
|
||||
Effect.map(Option.getOrNull),
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("generation failed", { error: Cause.pretty(cause) })
|
||||
return Effect.succeed(null)
|
||||
}),
|
||||
)
|
||||
return { branch }
|
||||
})
|
||||
|
||||
return handlers.handle("generate", handle)
|
||||
}),
|
||||
)
|
||||
@@ -79,6 +79,11 @@ export function matchLegacyKiloOpenApi(input: Record<string, unknown>) {
|
||||
const pty = spec.components?.schemas?.Pty?.properties
|
||||
if (pty?.sessionID) pty.sessionID = nullable(pty.sessionID)
|
||||
|
||||
const out = spec.paths?.["/session/{sessionID}/branch-name"]?.post?.responses?.["200"]?.content?.[
|
||||
"application/json"
|
||||
]?.schema?.properties
|
||||
if (out?.branch) out.branch = nullable(out.branch)
|
||||
|
||||
const update = spec.paths?.["/pty/{ptyID}"]?.put?.requestBody?.content?.["application/json"]?.schema
|
||||
const name = update?.$ref?.replace("#/components/schemas/", "")
|
||||
const fields = name ? spec.components?.schemas?.[name]?.properties : update?.properties
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as AnacondaDesktop from "@/kilocode/anaconda-desktop/service"
|
||||
import { agentBuilderHandlers } from "./handlers/agent-builder"
|
||||
import { anacondaDesktopHandlers } from "./handlers/anaconda-desktop"
|
||||
import { backgroundProcessHandlers } from "./handlers/background-process"
|
||||
import { branchNameHandlers } from "./handlers/branch-name"
|
||||
import { commitMessageHandlers } from "./handlers/commit-message"
|
||||
import { configConsoleHandlers } from "./handlers/config-console"
|
||||
import { enhancePromptHandlers } from "./handlers/enhance-prompt"
|
||||
@@ -28,6 +29,7 @@ export const provide = Layer.provide([
|
||||
agentBuilderHandlers,
|
||||
anacondaDesktopHandlers.pipe(Layer.provide(AnacondaDesktop.liveLayer)),
|
||||
backgroundProcessHandlers,
|
||||
branchNameHandlers,
|
||||
commitMessageHandlers,
|
||||
configConsoleHandlers,
|
||||
enhancePromptHandlers,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { WorkspaceApi } from "./groups/workspace"
|
||||
import { V2Api } from "./groups/v2"
|
||||
// kilocode_change start - Kilo HttpApi groups
|
||||
import { AgentBuilderApi } from "@/kilocode/server/httpapi/groups/agent-builder"
|
||||
import { BranchNameApi } from "@/kilocode/server/httpapi/groups/branch-name"
|
||||
import { CommitMessageApi } from "@/kilocode/server/httpapi/groups/commit-message"
|
||||
import { BackgroundProcessApi } from "@/kilocode/server/httpapi/groups/background-process"
|
||||
import { ConfigConsoleApi } from "@/kilocode/server/httpapi/groups/config-console"
|
||||
@@ -69,6 +70,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
// kilocode_change start - Kilo HttpApi groups
|
||||
.addHttpApi(AgentBuilderApi)
|
||||
.addHttpApi(BackgroundProcessApi)
|
||||
.addHttpApi(BranchNameApi)
|
||||
.addHttpApi(CommitMessageApi)
|
||||
.addHttpApi(ConfigConsoleApi)
|
||||
.addHttpApi(EnhancePromptApi)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { messages, parse } from "../../src/kilocode/branch-name"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
|
||||
function user(text: string, synthetic = false): MessageV2.WithParts {
|
||||
const sessionID = SessionID.make("ses_branch_name_test")
|
||||
const messageID = MessageID.ascending()
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "code",
|
||||
model: {
|
||||
providerID: ProviderID.make("kilo"),
|
||||
modelID: ModelID.make("kilo-auto/small"),
|
||||
},
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
synthetic,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe("branch name generation helpers", () => {
|
||||
test("sanitizes model output into a safe branch segment", () => {
|
||||
expect(parse("fix-token-refresh-race")).toBe("fix-token-refresh-race")
|
||||
expect(parse("Fix OAuth / Token Refresh!")).toBe("fix-oauth-token-refresh")
|
||||
expect(parse("feature")).toBe("feature")
|
||||
expect(parse("null")).toBeNull()
|
||||
expect(parse("!!!")).toBeNull()
|
||||
})
|
||||
|
||||
test("removes reasoning wrappers before parsing", () => {
|
||||
expect(parse("<think>Choose a durable outcome</think>\nadd-health-check-endpoint")).toBe(
|
||||
"add-health-check-endpoint",
|
||||
)
|
||||
expect(parse("<THINK>Choose a durable outcome</THINK>\nadd-health-check-endpoint")).toBe(
|
||||
"add-health-check-endpoint",
|
||||
)
|
||||
})
|
||||
|
||||
test("uses recent real user messages and appends the pending prompt once", () => {
|
||||
const history = [user("hi"), user("internal", true), user("Can you inspect auth?")]
|
||||
expect(messages(history, "Fix the token refresh race")).toEqual([
|
||||
"hi",
|
||||
"Can you inspect auth?",
|
||||
"Fix the token refresh race",
|
||||
])
|
||||
expect(messages([...history, user("Fix the token refresh race")], "Fix the token refresh race")).toEqual([
|
||||
"hi",
|
||||
"Can you inspect auth?",
|
||||
"Fix the token refresh race",
|
||||
])
|
||||
expect(messages([...history, user("Fix the token refresh race")], "Fix the token refresh race")).toEqual([
|
||||
"hi",
|
||||
"Can you inspect auth?",
|
||||
"Fix the token refresh race",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps only the latest four user messages", () => {
|
||||
const history = [user("one"), user("two"), user("three"), user("four"), user("five")]
|
||||
expect(messages(history, "six")).toEqual(["three", "four", "five", "six"])
|
||||
})
|
||||
|
||||
test("truncates large messages before generation", () => {
|
||||
const big = "x".repeat(2_000)
|
||||
|
||||
expect(messages([user(big)], big)).toEqual(["x".repeat(1_000)])
|
||||
})
|
||||
})
|
||||
@@ -315,6 +315,14 @@ export const kiloScenarios: Scenario[] = [
|
||||
.post("/commit-message", "commitMessage.generate")
|
||||
.at((ctx) => ({ path: "/commit-message", headers: ctx.headers(), body: {} }))
|
||||
.status(400),
|
||||
http.protected
|
||||
.post("/session/{sessionID}/branch-name", "branchName.generate")
|
||||
.at((ctx) => ({
|
||||
path: route("/session/{sessionID}/branch-name", { sessionID: "ses_httpapi_missing" }),
|
||||
headers: ctx.headers(),
|
||||
body: {},
|
||||
}))
|
||||
.status(400),
|
||||
http.protected
|
||||
.post("/enhance-prompt", "enhancePrompt.enhance")
|
||||
.at((ctx) => ({ path: "/enhance-prompt", headers: ctx.headers(), body: { text: "" } }))
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Result, Schema as EffectSchema } from "effect"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { AgentBuilderPaths } from "../../../src/kilocode/server/httpapi/groups/agent-builder"
|
||||
import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/groups/background-process"
|
||||
import { BranchNamePaths } from "../../../src/kilocode/server/httpapi/groups/branch-name"
|
||||
import { ConfigConsolePaths } from "../../../src/kilocode/server/httpapi/groups/config-console"
|
||||
import { IndexingPaths, KiloEmbeddingModel } from "../../../src/kilocode/server/httpapi/groups/indexing"
|
||||
import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway"
|
||||
@@ -138,6 +139,7 @@ describe("Kilo PublicApi OpenAPI contract", () => {
|
||||
{ method: "get", path: ConfigConsolePaths.tuiKeybinds },
|
||||
{ method: "patch", path: ConfigConsolePaths.tuiConfig },
|
||||
{ method: "get", path: KilocodePaths.sessionModelUsage },
|
||||
{ method: "post", path: BranchNamePaths.generate },
|
||||
] satisfies Array<{ method: Method; path: string }>
|
||||
|
||||
for (const route of routes) {
|
||||
@@ -157,6 +159,15 @@ describe("Kilo PublicApi OpenAPI contract", () => {
|
||||
expect(props?.organizationId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
|
||||
})
|
||||
|
||||
test("keeps branch-name responses nullable", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
const path = BranchNamePaths.generate.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
|
||||
const body = spec.paths[path]?.post?.responses?.["200"] as Body | undefined
|
||||
const branch = body?.content?.["application/json"]?.schema?.properties?.branch
|
||||
|
||||
expect(branch).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
|
||||
})
|
||||
|
||||
test("keeps Kilo gateway responses nullable", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
const response = (path: string) => {
|
||||
|
||||
@@ -37,6 +37,8 @@ import type {
|
||||
BackgroundProcessStopResponses,
|
||||
BackgroundProcessStopSessionErrors,
|
||||
BackgroundProcessStopSessionResponses,
|
||||
BranchNameGenerateErrors,
|
||||
BranchNameGenerateResponses,
|
||||
CommandListErrors,
|
||||
CommandListResponses,
|
||||
CommitMessageGenerateErrors,
|
||||
@@ -6249,6 +6251,51 @@ export class BackgroundProcess extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class BranchName extends HeyApiClient {
|
||||
/**
|
||||
* Generate branch name
|
||||
*
|
||||
* Generate a task-focused branch name from the current conversation.
|
||||
*/
|
||||
public generate<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
prompt?: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "prompt" },
|
||||
{ in: "body", key: "providerID" },
|
||||
{ in: "body", key: "modelID" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<BranchNameGenerateResponses, BranchNameGenerateErrors, ThrowOnError>({
|
||||
url: "/session/{sessionID}/branch-name",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class CommitMessage extends HeyApiClient {
|
||||
/**
|
||||
* Generate commit message
|
||||
@@ -8591,6 +8638,11 @@ export class KiloClient extends HeyApiClient {
|
||||
return (this._backgroundProcess ??= new BackgroundProcess({ client: this.client }))
|
||||
}
|
||||
|
||||
private _branchName?: BranchName
|
||||
get branchName(): BranchName {
|
||||
return (this._branchName ??= new BranchName({ client: this.client }))
|
||||
}
|
||||
|
||||
private _commitMessage?: CommitMessage
|
||||
get commitMessage(): CommitMessage {
|
||||
return (this._commitMessage ??= new CommitMessage({ client: this.client }))
|
||||
|
||||
@@ -9805,6 +9805,42 @@ export type BackgroundProcessStopSessionResponses = {
|
||||
export type BackgroundProcessStopSessionResponse =
|
||||
BackgroundProcessStopSessionResponses[keyof BackgroundProcessStopSessionResponses]
|
||||
|
||||
export type BranchNameGenerateData = {
|
||||
body?: {
|
||||
prompt: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
path: {
|
||||
sessionID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/session/{sessionID}/branch-name"
|
||||
}
|
||||
|
||||
export type BranchNameGenerateErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type BranchNameGenerateError = BranchNameGenerateErrors[keyof BranchNameGenerateErrors]
|
||||
|
||||
export type BranchNameGenerateResponses = {
|
||||
/**
|
||||
* Generated branch name or null when the task is not clear yet
|
||||
*/
|
||||
200: {
|
||||
branch: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type BranchNameGenerateResponse = BranchNameGenerateResponses[keyof BranchNameGenerateResponses]
|
||||
|
||||
export type CommitMessageGenerateData = {
|
||||
body?: {
|
||||
/**
|
||||
|
||||
@@ -11800,6 +11800,106 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/session/{sessionID}/branch-name": {
|
||||
"post": {
|
||||
"tags": ["branch-name"],
|
||||
"operationId": "branchName.generate",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Generated branch name or null when the task is not clear yet",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["branch"],
|
||||
"additionalProperties": false,
|
||||
"description": "Generated branch name or null when the task is not clear yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Generate a task-focused branch name from the current conversation.",
|
||||
"summary": "Generate branch name",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.branchName.generate({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/commit-message": {
|
||||
"post": {
|
||||
"tags": ["commit-message"],
|
||||
@@ -32953,6 +33053,10 @@
|
||||
"name": "background-process",
|
||||
"description": "Kilo background process routes."
|
||||
},
|
||||
{
|
||||
"name": "branch-name",
|
||||
"description": "Kilo branch name routes."
|
||||
},
|
||||
{
|
||||
"name": "commit-message",
|
||||
"description": "Kilo commit message routes."
|
||||
|
||||
Reference in New Issue
Block a user