Merge remote-tracking branch 'origin/main' into feat/show-approval-reason-outside-workspace-reads-and-writes

This commit is contained in:
Bruno Agatao
2026-08-07 19:29:19 +02:00
57 changed files with 3459 additions and 185 deletions
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Route Agent Manager tool-launched sessions to the project that owns the tool event directory, keep sandboxed worktree sessions inside their active worktree, and wait for busy managed sessions before prompting them.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/kilo-ui": patch
"kilo-code": patch
---
Fix flickering and sticky scrolling when scrolling up in Agent Manager and chat sessions.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Surface process exit signals and structured spawn failure details in server startup diagnostics
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Long skill folder paths and URLs shown in the tooltip on the Skills settings page now wrap inside the viewport instead of overflowing on a single line.
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-epitmtKUd9fAucKdH/sDdqv5WmwfpiPH+h/PNt55gd4=",
"aarch64-linux": "sha256-FHVsi2iho+U5aj6Z9lg2GTIu1ViUuqI2c/nZuwjmPH8=",
"aarch64-darwin": "sha256-QDPVLcbTyaZRZvSnqvWNR/mIkS1TrZLX0HjUlg6xwwI=",
"x86_64-darwin": "sha256-SSPc9b3WwcYCnuywcyMRJjVlugoXuo0UoPJ+qzPGajk="
"x86_64-linux": "sha256-y6PZR6BsVZcsK/qJ0XvJBevukYzVJ8NRLHRbNSuSzb8=",
"aarch64-linux": "sha256-0ozGLgGTHajlHqofixtjJv/dggq25MhWYIqmfmb+6Z0=",
"aarch64-darwin": "sha256-IEYJothLBDT20BmLamBqM+pciFMQtWy8Um1YjFjbKz4=",
"x86_64-darwin": "sha256-k2bQGKTkvIZmB2+I2Fy3TPm/NZ0BbGjxp9esxtGuUbI="
}
}
@@ -110,7 +110,19 @@ Before using Kilo for Slack:
## Setup
To install Kilo for Slack, go to the Integrations menu in the sidebar at [app.kilo.ai](https://app.kilo.ai) and set up the Slack integration.
To install Kilo for Slack, go to the Integrations menu in the sidebar at [app.kilo.ai](https://app.kilo.ai) and set up the Slack integration:
{% image src="/docs/img/connect/slack/slackbot-integrations.webp" alt="Kilo Integrations page where you install the Slack integration" width="800" /%}
Then hit the Configure button, which will take you to this page:
{% image src="/docs/img/connect/slack/slackbot-integrations-2.webp" alt="Kilo Integrations page where you install the Slack integration, step 2" width="800" /%}
Once you press "Connect with Slack", the Slack OAuth authorization page appears:
{% image src="/docs/img/connect/slack/slackbot-slack-authorize.webp" alt="Slack OAuth authorization page" width="800" /%}
Once this is completed you're good to go!
To make proper use of all of the Kilo Slack bot's capabilities, it's important that you also connect your source control provider with Kilo:
| Platform | Integration Type | Details |
|---|---|---|
+2786
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
core-js: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

@@ -216,4 +216,50 @@ describe("createAutoScroll non-scrollable layouts", () => {
expect(ctx.el.scrollTop).toBe(300)
ctx.dispose()
})
test("does not snap to bottom on content resize after user scrolls up while idle", () => {
const ctx = setup({ working: false })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800 // at bottom
// User wheels up
const event = new FakeWheelEvent(-50, ctx.el)
ctx.el.fire("wheel", event as unknown as Event)
ctx.el.scrollTop = 750
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
// Virtual list re-measures / resizes content
ctx.el.scrollHeight = 1100
ctx.resize()
// Must NOT snap to bottom (1100), must remain at user scroll position (750)
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(750)
ctx.dispose()
})
test("does not snap to bottom when dragging scrollbar up while idle", () => {
const ctx = setup({ working: false })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800 // at bottom
// User presses pointerdown on scrollbar and drags up
ctx.el.fire("pointerdown", new Event("pointerdown"))
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
// Content resize during drag
ctx.el.scrollHeight = 1050
ctx.resize()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
})
})
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { canScroll, distanceFromBottom } from "./auto-scroll"
import { createUserActivity } from "./scroll-user-activity"
const DEBOUNCE_MS = 100
// Grace window after a real pointer/key/touch interaction during which a
// ResizeObserver or non-user scroll event must not snap the view back to the
// bottom. Upward wheel intent pauses immediately in its capture handler.
@@ -14,6 +13,7 @@ export interface AutoScrollOptions {
working: () => boolean
onUserInteracted?: () => void
bottomThreshold?: number
overflowAnchor?: "none" | "auto" | "dynamic"
}
export function createAutoScroll(options: AutoScrollOptions) {
@@ -24,7 +24,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let stopTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
const [store, setStore] = createStore({
@@ -100,7 +99,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
const handleScroll = () => {
if (!scroll) return
const input = userActivity.consumeScroll()
userActivity.consumeScroll()
const distance = distanceFromBottom(scroll)
if (!canScroll(scroll)) return
@@ -110,52 +109,25 @@ export function createAutoScroll(options: AutoScrollOptions) {
return
}
if (!store.userScrolled && !input) {
// Only explicit user input can pause following. Treat unclassified
// scroll events from virtualization or layout changes as programmatic.
if (userActivity.isRecent()) {
stop()
} else {
bottom()
}
return
}
// Debounce to avoid layout-induced scroll shifts (e.g. images loading,
// virtual-list reflows) from incorrectly breaking auto-follow.
if (stopTimer) clearTimeout(stopTimer)
stopTimer = setTimeout(() => {
stopTimer = undefined
if (!scroll) return
if (distanceFromBottom(scroll) < threshold()) return
stop()
}, DEBOUNCE_MS)
stop()
}
const onContentResize = () => {
if (scroll && !canScroll(scroll)) return
if (!active()) {
if (!store.userScrolled && scroll && distanceFromBottom(scroll) > threshold()) {
bottom()
return
}
return
}
if (store.userScrolled) {
return
}
// Virtualized lists (virtua) re-measure items during user scroll, firing
// resize events that race ahead of handleScroll's DEBOUNCE_MS window.
// If the user just interacted with the scroller and is no longer near
// the bottom, treat the resize as a layout reflow on top of their
// scroll — pause auto-follow instead of snapping back to the bottom.
if (scroll && userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) {
if (!scroll || !canScroll(scroll)) return
if (store.userScrolled) return
if (userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) {
stop()
return
}
// ResizeObserver fires after layout, before paint.
// Keep the bottom locked in the same frame to avoid visible
// "jump up then catch up" artifacts while streaming content.
if (!active()) {
if (!userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) {
bottom()
}
return
}
follow()
}
@@ -173,6 +145,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
createResizeObserver(() => store.contentRef, onContentResize)
createResizeObserver(() => store.scrollRef, onViewportResize)
createEffect(
on(
() => store.userScrolled,
() => {
if (scroll) updateOverflowAnchor(scroll)
},
),
)
createEffect(
on(options.working, (working: boolean) => {
settling = false
@@ -195,6 +176,19 @@ export function createAutoScroll(options: AutoScrollOptions) {
// Lifecycle
// ---------------------------------------------------------------------------
const updateOverflowAnchor = (el: HTMLElement) => {
const mode = options.overflowAnchor ?? "none"
if (mode === "none") {
el.style.overflowAnchor = "none"
return
}
if (mode === "auto") {
el.style.overflowAnchor = "auto"
return
}
el.style.overflowAnchor = store.userScrolled ? "auto" : "none"
}
const setScroll = (el: HTMLElement | undefined) => {
if (cleanup) {
cleanup()
@@ -206,13 +200,12 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (!el) return
el.style.overflowAnchor = "auto"
updateOverflowAnchor(el)
cleanup = userActivity.listen(el)
}
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
if (stopTimer) clearTimeout(stopTimer)
if (cleanup) cleanup()
})
@@ -18,13 +18,16 @@ export const createUserActivity = (options: UserActivityOptions) => {
// do not get mistaken for the user leaving auto-follow mode.
const mark = (event: Event) => {
if (!isPotentialScrollInput(event)) return
if (scroll && scroll.scrollHeight - scroll.clientHeight <= 1) return
marked = true
time = performance.now()
}
const handleWheel = (event: WheelEvent) => {
if (event.deltaY >= 0 || !scroll || scroll.scrollTop <= 0) return
time = performance.now()
if (!isPotentialScrollInput(event)) return
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
mark(event)
if (event.deltaY >= 0 || scroll.scrollTop <= 0) return
options.onWheelUp()
}
@@ -6,7 +6,7 @@ import { watchFontSizeConfig } from "./kilo-provider/font-size"
import { mapSSEEventToWebviewMessage } from "./kilo-provider-utils"
import { resolvePanelProjectDirectory } from "./project-directory"
import { seedSessionStatuses } from "./session-status"
import type { KiloConnectionService } from "./services/cli-backend"
import { type KiloConnectionService, ServerStartupError } from "./services/cli-backend"
import { MarketplaceService } from "./services/marketplace"
import {
fetchMarketplaceData,
@@ -123,7 +123,15 @@ export class MarketplacePanelProvider implements vscode.Disposable {
)
this.subscriptions.push(
this.connection.onStateChange((state, err) => {
this.post({ type: "connectionState", state, ...(err ? { error: err.message } : {}) })
this.post({
type: "connectionState",
state,
...(err ? { error: err.message } : {}),
...(err instanceof ServerStartupError && {
userMessage: err.userMessage,
userDetails: err.userDetails,
}),
})
if (state === "connected") void this.sync(false)
}),
this.connection.onLanguageChanged((locale) => this.post({ type: "languageChanged", locale })),
@@ -155,7 +163,15 @@ export class MarketplacePanelProvider implements vscode.Disposable {
await this.connection.connect(this.directory())
await this.sync(this.statuses.size === 0)
} catch (err) {
this.post({ type: "connectionState", state: "error", error: err instanceof Error ? err.message : String(err) })
this.post({
type: "connectionState",
state: "error",
error: err instanceof Error ? err.message : String(err),
...(err instanceof ServerStartupError && {
userMessage: err.userMessage,
userDetails: err.userDetails,
}),
})
}
}
@@ -53,8 +53,10 @@ import {
import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project/init"
import { createLocalDiff } from "./local-diff"
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
import { handleToolEvent } from "./tool-project"
import { sandboxSessionMetadata } from "../shared/sandbox-session"
import { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
import { createOrchestrationBridge } from "./orchestration-setup"
import type { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
import { pruneSubagents } from "./prune-subagents"
import { startSession } from "./mcp-warmup"
import { readTerminalFont, watchTerminalFont } from "./terminal-font"
@@ -253,22 +255,20 @@ export class AgentManagerProvider implements Disposable {
this.statsPoller = pollers.stats
this.prBridge = pollers.pr
this.projectPollers = pollers.projects
this.orchestration = new AgentManagerOrchestrationBridge(this.connectionService, {
root: () => this.getRoot(),
state: () => this.state,
ready: async () => {
this.stateReady ??= this.initializeState()
await this.stateReady
return this.state
},
stats: () => this.statsPoller.snapshot(),
prs: () => this.prBridge.snapshot(),
push: () => this.pushState(),
managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id),
close: async (id) => {
await this.onCloseSession(id)
this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id })
},
this.orchestration = createOrchestrationBridge({
connectionService: this.connectionService,
contexts: this.contexts,
projectScope: this.projectScope,
getRoot: () => this.getRoot(),
getState: () => this.state,
getStateReady: () => this.stateReady,
initStateReady: () => (this.stateReady = this.initializeState()),
getStats: () => this.statsPoller.snapshot(),
getPrs: () => this.prBridge.snapshot(),
pushState: (ctx) => this.pushState(ctx),
hasPanelSession: (id) => this.panelSessions.has(id),
closeSession: (id) => this.onCloseSession(id),
postSessionClosed: (id) => this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id }),
log: (...args) => this.log(...args),
})
this.unsubTool = this.connectionService.onEventFiltered(
@@ -1083,14 +1083,16 @@ export class AgentManagerProvider implements Disposable {
}
private onToolEvent(event: unknown, directory?: string): void {
const properties = (event as { properties?: unknown }).properties
const req = parseToolRequest(properties)
if (!req) return
if (directory) {
req.directory = directory
req.projectId ??= this.contexts.byDirectory(directory)?.id
}
void this.startToolRequest(req)
handleToolEvent(
event,
directory,
{
byDirectory: (value) => this.contexts.byDirectory(value),
usable: (id) => this.contexts.usable(id),
},
this.projectScope,
(req) => this.startToolRequest(req),
)
}
private async startToolRequest(req: ToolRequest): Promise<void> {
@@ -1829,19 +1831,15 @@ export class AgentManagerProvider implements Disposable {
public async createFromSidebar(baseBranch?: string, branchName?: string): Promise<void> {
this.openPanel()
const panel = this.panel
if (!panel) return
if (!(await this.waitForPanelReady(panel))) return
if (!this.panel || !(await this.waitForPanelReady(this.panel))) return
await this.waitForStateReady("createFromSidebar")
await this.onCreateWorktree(baseBranch, branchName)
}
public async openAdvancedWorktree(): Promise<void> {
this.openPanel()
const panel = this.panel
if (!panel) return
if (!(await this.waitForPanelActive(panel))) return
if (!(await this.waitForPanelReady(panel))) return
if (!this.panel || !(await this.waitForPanelActive(this.panel)) || !(await this.waitForPanelReady(this.panel)))
return
await this.waitForStateReady("openAdvancedWorktree")
queueMicrotask(() => this.postToWebview({ type: "action", action: "advancedWorktree" }))
}
@@ -41,14 +41,15 @@ interface Failure {
}
interface Options {
root(): string | undefined
ready(): Promise<WorktreeStateManager | undefined>
state(): WorktreeStateManager | undefined
stats(): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
prs(): Map<string, PRStatus>
push(): void
managed(sessionID: string): boolean
close(sessionID: string): Promise<void>
root(directory?: string): string | undefined
ready(directory?: string): Promise<WorktreeStateManager | undefined>
state(directory?: string): WorktreeStateManager | undefined
stats(directory?: string): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
prs(directory?: string): Map<string, PRStatus>
push(directory?: string): void
managed(sessionID: string, directory?: string): boolean
close(sessionID: string, directory?: string): Promise<void>
directories?(): string[]
log(...args: unknown[]): void
}
@@ -108,6 +109,7 @@ export class AgentManagerOrchestrationBridge {
})
})
this.unsubscribeDirectories = connection.registerDirectoryProvider(() => {
if (this.options.directories) return this.options.directories()
const root = this.options.root()
const dirs =
this.options
@@ -181,8 +183,8 @@ export class AgentManagerOrchestrationBridge {
}
private async admit(request: Request, directory: string): Promise<void> {
const state = await this.options.ready()
const root = this.options.root()
const state = await this.options.ready(directory)
const root = this.options.root(directory)
if (this.disposed || this.settled.has(request.id)) return
if (!state || !root) {
const accepted = await this.reject(request.id, directory, {
@@ -232,7 +234,7 @@ export class AgentManagerOrchestrationBridge {
private async run(request: Request, origin: Origin, active: Active): Promise<void> {
try {
const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, active))
const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, origin, active))
if (!outcome || this.disposed || active.cancelled) return
this.rememberOutcome(request.id, outcome)
const accepted =
@@ -248,10 +250,10 @@ export class AgentManagerOrchestrationBridge {
}
}
private async execute(request: Request, active: Active): Promise<Outcome | undefined> {
private async execute(request: Request, origin: Origin, active: Active): Promise<Outcome | undefined> {
try {
const state = await this.options.ready()
const root = this.options.root()
const state = await this.options.ready(origin.directory)
const root = this.options.root(origin.directory)
if (!state || !root)
throw new OrchestrationError("workspace_unavailable", "Agent Manager requires an open workspace")
if (this.disposed || active.cancelled) return
@@ -260,7 +262,7 @@ export class AgentManagerOrchestrationBridge {
// Git stats are refreshed by the poller independently. A forced refresh
// here can spawn one diff/ahead-behind pair per worktree and exceed the
// host request timeout before the overview can return its IDs.
const stats = await this.options.stats()
const stats = await this.options.stats(origin.directory)
if (this.disposed || active.cancelled) return
const result = await overview({
client,
@@ -269,7 +271,7 @@ export class AgentManagerOrchestrationBridge {
titles: this.titles,
filter: request.filter,
stats,
prs: this.options.prs(),
prs: this.options.prs(origin.directory),
})
return { result: { operation: "overview", overview: result } }
}
@@ -288,7 +290,7 @@ export class AgentManagerOrchestrationBridge {
}
if (request.operation === "move") {
move({ state, sessionID: request.targetSessionID, sectionID: request.sectionID })
this.options.push()
this.options.push(origin.directory)
if (this.disposed || active.cancelled) return
return {
result: {
@@ -299,10 +301,10 @@ export class AgentManagerOrchestrationBridge {
},
}
}
if (!this.options.managed(request.targetSessionID)) {
if (!this.options.managed(request.targetSessionID, origin.directory)) {
throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace")
}
await this.options.close(request.targetSessionID)
await this.options.close(request.targetSessionID, origin.directory)
if (this.disposed || active.cancelled) return
return { result: { operation: "stop", sessionID: request.targetSessionID, stopped: true } }
} catch (error) {
@@ -319,6 +319,7 @@ export async function prompt(input: {
text: string
messageID: string
signal?: AbortSignal
idleTimeoutMs?: number
}): Promise<void> {
if (input.signal?.aborted) return
const managed = input.state.getSession(input.sessionID)
@@ -342,15 +343,7 @@ export async function prompt(input: {
if (!(await sameManagedDirectory(response.data.directory, dir))) {
throw new OrchestrationError("cross_workspace", "The managed session belongs to a different workspace directory")
}
const status = await input.client.session.status({ directory: dir })
if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read")
const activity = status.data?.[input.sessionID]?.type ?? "idle"
if (activity !== "idle") {
throw new OrchestrationError(
"unavailable_session",
`The managed session is ${activity}; only idle sessions can be prompted`,
)
}
await waitForIdle(input.client, dir, input.sessionID, input.signal, input.idleTimeoutMs ?? 30_000)
if (input.signal?.aborted) return
await input.client.session.promptAsync(
{
@@ -364,6 +357,29 @@ export async function prompt(input: {
)
}
async function waitForIdle(
client: KiloClient,
directory: string,
sessionID: string,
signal: AbortSignal | undefined,
timeout: number,
start = Date.now(),
): Promise<void> {
if (signal?.aborted) return
const status = await client.session.status({ directory })
if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read")
const activity = status.data?.[sessionID]?.type ?? "idle"
if (activity === "idle") return
if (Date.now() - start >= timeout) {
throw new OrchestrationError(
"unavailable_session",
`The managed session is still ${activity}; only idle sessions can be prompted`,
)
}
await new Promise<void>((resolve) => setTimeout(resolve, 250))
return waitForIdle(client, directory, sessionID, signal, timeout, start)
}
export function move(input: { state: WorktreeStateManager; sessionID: string; sectionID: string | null }): void {
const session = input.state.getSession(input.sessionID)
if (!session)
@@ -0,0 +1,78 @@
import type { KiloConnectionService } from "../services/cli-backend/connection-service"
import { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
import type { ProjectContexts } from "./project/contexts"
import type { ProjectContext } from "./project/context"
import type { ProjectScope } from "./project/scope"
import type { WorktreeStateManager } from "./WorktreeStateManager"
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
import type { PRStatus } from "./types"
import { initContextState } from "./project/init"
export interface OrchestrationBridgeDeps {
connectionService: KiloConnectionService
contexts: ProjectContexts
projectScope: ProjectScope
getRoot: () => string | undefined
getState: () => WorktreeStateManager | undefined
getStateReady: () => Promise<void> | undefined
initStateReady: () => Promise<void>
getStats: () => Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
getPrs: () => Map<string, PRStatus>
pushState: (ctx?: ProjectContext) => void
hasPanelSession: (id: string) => boolean
closeSession: (id: string) => Promise<unknown>
postSessionClosed: (id: string) => void
log: (...args: unknown[]) => void
}
export function createOrchestrationBridge(deps: OrchestrationBridgeDeps): AgentManagerOrchestrationBridge {
return new AgentManagerOrchestrationBridge(deps.connectionService, {
root: (dir) => (dir ? deps.contexts.byDirectory(dir)?.root : undefined) ?? deps.getRoot(),
state: (dir) => (dir ? deps.contexts.byDirectory(dir)?.peekState() : undefined) ?? deps.getState(),
ready: async (dir) => {
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
if (ctx && ctx.id !== deps.contexts.active()?.id) {
await initContextState(ctx, (...args) => deps.log(...args))
return ctx.stateManager()
}
const ready = deps.getStateReady() ?? deps.initStateReady()
await ready
return deps.getState()
},
stats: () => deps.getStats(),
prs: () => deps.getPrs(),
push: (dir) => {
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
deps.pushState(ctx)
},
managed: (id, dir) => {
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
if (ctx) return ctx.hasLiveSession(id) || !!ctx.peekState()?.getSession(id)
return deps.hasPanelSession(id) || !!deps.getState()?.getSession(id)
},
close: async (id, dir) => {
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
if (ctx) {
await deps.projectScope.run(ctx, () => deps.closeSession(id))
} else {
await deps.closeSession(id)
}
deps.postSessionClosed(id)
},
directories: () => {
const all: string[] = []
for (const ctx of deps.contexts.values()) {
all.push(ctx.root)
for (const wt of ctx.peekState()?.getWorktrees() ?? []) {
if (wt.path) all.push(wt.path)
}
}
if (all.length === 0) {
const root = deps.getRoot()
if (root) all.push(root)
}
return all
},
log: (...args) => deps.log(...args),
})
}
@@ -100,6 +100,10 @@ export class ProjectContexts {
return this.contexts.get(id)
}
values(): IterableIterator<ProjectContext> {
return this.contexts.values()
}
/** The context that owns a directory: its root or one of its worktree paths. */
byDirectory(dir: string): ProjectContext | undefined {
for (const ctx of this.contexts.values()) {
@@ -0,0 +1,32 @@
import type { ToolRequest } from "./tool-start"
import { parseToolRequest } from "./tool-start"
export function routeToolRequest<T extends { projectId?: string; directory?: string }, C extends { id: string }>(
input: T,
directory: string | undefined,
deps: { byDirectory: (value: string) => C | undefined; usable: (id: string) => C | undefined },
): { request: T; owner?: C } {
const request = directory ? { ...input, directory } : input
const owner =
(directory && deps.byDirectory(directory)) ?? (request.projectId ? deps.usable(request.projectId) : undefined)
if (!owner) return { request }
return { request: { ...request, projectId: owner.id }, owner }
}
export function handleToolEvent<C extends { id: string }>(
event: unknown,
directory: string | undefined,
contexts: { byDirectory: (value: string) => C | undefined; usable: (id: string) => C | undefined },
scope: { run: <T>(owner: C, fn: () => Promise<T>) => Promise<T> },
start: (req: ToolRequest) => Promise<void>,
): void {
const properties = (event as { properties?: unknown }).properties
const req = parseToolRequest(properties)
if (!req) return
const routed = routeToolRequest(req, directory, contexts)
if (routed.owner) {
void scope.run(routed.owner, () => start(routed.request))
return
}
void start(routed.request)
}
@@ -282,3 +282,25 @@ describe("KiloConnectionService drainPendingPrompts", () => {
expect(cleared).toBe(0)
})
})
describe("KiloConnectionService server exit handling", () => {
test("reports signal name when process is killed by signal", () => {
const service = new KiloConnectionService({} as any)
let stateErr: Error | undefined
service.onStateChange((state, err) => {
if (state === "error") stateErr = err
})
;(service as any).handleServerExit(null, "SIGSEGV")
expect(stateErr?.message).toBe("CLI background process exited with signal SIGSEGV. Retry to reconnect.")
})
test("reports exit code when process exits normally with code", () => {
const service = new KiloConnectionService({} as any)
let stateErr: Error | undefined
service.onStateChange((state, err) => {
if (state === "error") stateErr = err
})
;(service as any).handleServerExit(1, null)
expect(stateErr?.message).toBe("CLI background process exited with code 1. Retry to reconnect.")
})
})
@@ -138,7 +138,7 @@ export class KiloConnectionService {
update: async () => undefined,
} satisfies Pick<vscode.Memento, "get" | "update">)
this.sandboxPreference = new SandboxPreference(state)
this.serverManager = new ServerManager(context, (code) => this.handleServerExit(code))
this.serverManager = new ServerManager(context, (code, signal) => this.handleServerExit(code, signal))
this.active = vscode.window.state.focused
this.windowStateDisposable = vscode.window.onDidChangeWindowState((ws) => {
this.active = ws.focused
@@ -790,13 +790,11 @@ export class KiloConnectionService {
this.questionRevision += 1
}
private handleServerExit(code: number | null): void {
console.warn("[Kilo New] ConnectionService: CLI background process exited:", code)
private handleServerExit(code: number | null, signal: NodeJS.Signals | null): void {
const reason = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`
console.warn(`[Kilo New] ConnectionService: CLI background process exited with ${reason}`)
this.resetConnection()
this.setState(
"error",
new Error(`CLI background process exited with code ${code ?? "unknown"}. Retry to reconnect.`),
)
this.setState("error", new Error(`CLI background process exited with ${reason}. Retry to reconnect.`))
}
private async doConnect(workspaceDir: string): Promise<void> {
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "انتهت عملية CLI بالرمز {{code}} قبل بدء الخادم",
"server.startupTimeout": "انتهت مهلة بدء تشغيل الخادم بعد {{seconds}} ثانية",
"server.processExited": "تم إنهاء عملية CLI بالرمز {{code}} قبل بدء الخادم",
"server.processSignaled": "تم إنهاء عملية CLI بواسطة الإشارة {{signal}} قبل بدء الخادم",
"server.spawnFailed": "فشل في تشغيل الملف الثنائي لـ CLI ({{code}})",
"server.startupTimeout": "انتهت مهلة بدء تشغيل الخادم بعد {{seconds}} ثوانٍ",
"remote.connected": "Kilo Remote: متصل",
"remote.connecting": "Kilo Remote: جارٍ الاتصال\u2026",
} as const
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "O processo da CLI foi encerrado com o código {{code}} antes que o servidor fosse iniciado",
"server.startupTimeout": "Tempo limite de inicialização do servidor esgotado após {{seconds}} segundos",
"server.processExited": "O processo da CLI foi encerrado com o código {{code}} antes da inicialização do servidor",
"server.processSignaled": "O processo da CLI foi finalizado pelo sinal {{signal}} antes da inicialização do servidor",
"server.spawnFailed": "Falha ao gerar o binário da CLI ({{code}})",
"server.startupTimeout": "Tempo limite de inicialização do servidor atingido após {{seconds}} segundos",
"remote.connected": "Kilo Remote: Conectado",
"remote.connecting": "Kilo Remote: Conectando\u2026",
} as const
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "CLI proces je izašao sa kodom {{code}} prije nego što se server pokrenuo",
"server.startupTimeout": "Vrijeme pokretanja servera je isteklo nakon {{seconds}} sekundi",
"server.processExited": "CLI proces je izašao s kodom {{code}} prije nego što je server pokrenut",
"server.processSignaled": "CLI proces je prekinut signalom {{signal}} prije pokretanja servera",
"server.spawnFailed": "Neuspjelo pokretanje CLI binarne datoteke ({{code}})",
"server.startupTimeout": "Isteklo je vrijeme za pokretanje servera nakon {{seconds}} sekundi",
"remote.connected": "Kilo Remote: Povezano",
"remote.connecting": "Kilo Remote: Povezivanje\u2026",
} as const
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "CLI-processen afsluttede med kode {{code}} før serveren startede",
"server.startupTimeout": "Serverens opstartstid udløb efter {{seconds}} sekunder",
"server.processExited": "CLI-processen afsluttedes med kode {{code}}, før serveren startede",
"server.processSignaled": "CLI-processen blev afbrudt af signal {{signal}}, før serveren startede",
"server.spawnFailed": "Kunne ikke starte CLI-binærfil ({{code}})",
"server.startupTimeout": "Timeout for serverstart efter {{seconds}} sekunder",
"remote.connected": "Kilo Remote: Forbundet",
"remote.connecting": "Kilo Remote: Forbinder\u2026",
} as const
@@ -1,5 +1,8 @@
export const dict = {
"server.processExited": "Der CLI-Prozess wurde mit dem Code {{code}} beendet, bevor der Server gestartet wurde",
"server.processSignaled":
"Der CLI-Prozess wurde durch das Signal {{signal}} beendet, bevor der Server gestartet wurde",
"server.spawnFailed": "Fehler beim Starten der CLI-Binärdatei ({{code}})",
"server.startupTimeout": "Zeitüberschreitung beim Serverstart nach {{seconds}} Sekunden",
"remote.connected": "Kilo Remote: Verbunden",
"remote.connecting": "Kilo Remote: Verbindung wird hergestellt\u2026",
@@ -1,5 +1,7 @@
export const dict = {
"server.processExited": "CLI process exited with code {{code}} before server started",
"server.processSignaled": "CLI process terminated by signal {{signal}} before server started",
"server.spawnFailed": "Failed to spawn CLI binary ({{code}})",
"server.startupTimeout": "Server startup timeout after {{seconds}} seconds",
"remote.connected": "Kilo Remote: Connected",
"remote.connecting": "Kilo Remote: Connecting\u2026",
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "El proceso de la CLI finalizó con el código {{code}} antes de que se iniciara el servidor",
"server.startupTimeout": "Tiempo de espera de inicio del servidor agotado después de {{seconds}} segundos",
"server.processExited": "El proceso CLI finalizó con el código {{code}} antes de que se iniciara el servidor",
"server.processSignaled": "El proceso CLI fue terminado por la señal {{signal}} antes de que se iniciara el servidor",
"server.spawnFailed": "Error al generar el binario CLI ({{code}})",
"server.startupTimeout": "Tiempo de espera agotado para el inicio del servidor después de {{seconds}} segundos",
"remote.connected": "Kilo Remote: Conectado",
"remote.connecting": "Kilo Remote: Conectando\u2026",
} as const
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "فرآیند CLI با کد {{code}} قبل از راه‌اندازی سرور خاتمه یافت",
"server.processExited": "فرایند CLI قبل از شروع سرور با کد {{code}} خارج شد",
"server.processSignaled": "فرایند CLI با سیگنال {{signal}} قبل از شروع سرور متوقف شد",
"server.spawnFailed": "اجرای فایل باینری CLI ناموفق بود ({{code}})",
"server.startupTimeout": "زمان راه‌اندازی سرور پس از {{seconds}} ثانیه به پایان رسید",
"remote.connected": "Kilo Remote: متصل شد",
"remote.connecting": "Kilo Remote: در حال اتصال",
"remote.connecting": "Kilo Remote: در حال اتصال\u2026",
} as const
+6 -4
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "Le processus CLI s'est terminé avec le code {{code}} avant le démarrage du serveur",
"server.startupTimeout": "Délai de démarrage du serveur dépassé après {{seconds}} secondes",
"remote.connected": "Kilo Remote\u00a0: Connecté",
"remote.connecting": "Kilo Remote\u00a0: Connexion\u2026",
"server.processExited": "Le processus CLI a quitté avec le code {{code}} avant le démarrage du serveur",
"server.processSignaled": "Le processus CLI a été arrêté par le signal {{signal}} avant le démarrage du serveur",
"server.spawnFailed": "Échec du lancement du binaire CLI ({{code}})",
"server.startupTimeout": "Délai d'attente du démarrage du serveur dépassé après {{seconds}} secondes",
"remote.connected": "Kilo Remote : Connecté",
"remote.connecting": "Kilo Remote : Connexion en cours\u2026",
} as const
+5 -3
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "Il processo CLI è uscito con codice {{code}} prima dell'avvio del server",
"server.processExited": "Il processo CLI è terminato con il codice {{code}} prima dell'avvio del server",
"server.processSignaled": "Il processo CLI è stato terminato dal segnale {{signal}} prima dell'avvio del server",
"server.spawnFailed": "Impossibile avviare il binario CLI ({{code}})",
"server.startupTimeout": "Timeout di avvio del server dopo {{seconds}} secondi",
"remote.connected": "Kilo Remote: connesso",
"remote.connecting": "Kilo Remote: connessione...",
"remote.connected": "Kilo Remote: Connesso",
"remote.connecting": "Kilo Remote: Connessione in corso\u2026",
} as const
@@ -1,5 +1,7 @@
export const dict = {
"server.processExited": "サーバーが起動する前に、CLI プロセスがコード {{code}} で終了しました",
"server.processSignaled": "サーバーが起動する前に、CLI プロセスがシグナル {{signal}} で終了しました",
"server.spawnFailed": "CLI バイナリの起動に失敗しました ({{code}})",
"server.startupTimeout": "サーバーの起動が {{seconds}} 秒後にタイムアウトしました",
"remote.connected": "Kilo Remote: 接続済み",
"remote.connecting": "Kilo Remote: 接続中\u2026",
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "서버가 시작되기 전에 CLI 프로세스가 코드 {{code}}로 종료되었습니다",
"server.startupTimeout": "{{seconds}}초 후 서버 시작 시간이 초과되었습니다",
"server.processExited": "서버가 시작되기 전에 CLI 프로세스가 코드 {{code}}(으)로 종료되었습니다",
"server.processSignaled": "서버 시작되기 전에 CLI 프로세스가 신호 {{signal}}(으)로 종료되었습니다",
"server.spawnFailed": "CLI 바이너리를 생성하지 못했습니다 ({{code}})",
"server.startupTimeout": "{{seconds}}초 후 서버 시작 시간 초과",
"remote.connected": "Kilo Remote: 연결됨",
"remote.connecting": "Kilo Remote: 연결 중\u2026",
} as const
@@ -1,5 +1,7 @@
export const dict = {
"server.processExited": "CLI-proces is afgesloten met code {{code}} voordat de server is gestart",
"server.processSignaled": "CLI-proces is beëindigd door signaal {{signal}} voordat de server is gestart",
"server.spawnFailed": "Kan CLI-binair bestand niet starten ({{code}})",
"server.startupTimeout": "Time-out bij opstarten van server na {{seconds}} seconden",
"remote.connected": "Kilo Remote: Verbonden",
"remote.connecting": "Kilo Remote: Verbinden\u2026",
@@ -1,5 +1,7 @@
export const dict = {
"server.processExited": "CLI-prosessen avsluttet med kode {{code}} før serveren startet",
"server.processSignaled": "CLI-prosessen ble avsluttet av signal {{signal}} før serveren startet",
"server.spawnFailed": "Kunne ikke starte CLI-binærfil ({{code}})",
"server.startupTimeout": "Tidsavbrudd for serveroppstart etter {{seconds}} sekunder",
"remote.connected": "Kilo Remote: Tilkoblet",
"remote.connecting": "Kilo Remote: Kobler til\u2026",
+3 -1
View File
@@ -1,5 +1,7 @@
export const dict = {
"server.processExited": "Proces CLI zakończył się z kodem {{code}} przed uruchomieniem serwera",
"server.processExited": "Proces CLI zakończył działanie z kodem {{code}} przed uruchomieniem serwera",
"server.processSignaled": "Proces CLI został przerwany przez sygnał {{signal}} przed uruchomieniem serwera",
"server.spawnFailed": "Nie udało się uruchomić pliku binarnego CLI ({{code}})",
"server.startupTimeout": "Przekroczono limit czasu uruchamiania serwera po {{seconds}} sekundach",
"remote.connected": "Kilo Remote: Połączono",
"remote.connecting": "Kilo Remote: Łączenie\u2026",
@@ -1,5 +1,7 @@
export const dict = {
"server.processExited": "Процесс CLI завершился с кодом {{code}} до запуска сервера",
"server.processSignaled": "Процесс CLI был завершен сигналом {{signal}} до запуска сервера",
"server.spawnFailed": "Не удалось запустить исполняемый файл CLI ({{code}})",
"server.startupTimeout": "Время ожидания запуска сервера истекло через {{seconds}} секунд",
"remote.connected": "Kilo Remote: Подключено",
"remote.connecting": "Kilo Remote: Подключение\u2026",
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "กระบวนการ CLI ออกด้วยรหัส {{code}} ก่อนที่เซิร์ฟเวอร์จะเริ่มทำงาน",
"server.startupTimeout": "หมดเวลาการเริ่มต้นเซิร์ฟเวอร์หลังจาก {{seconds}} วินาที",
"server.processExited": "กระบวนการ CLI ออกด้วยรหัส {{code}} ก่อนที่เซิร์ฟเวอร์จะเริ่มต้น",
"server.processSignaled": "กระบวนการ CLI ถูกยุติโดยสัญญาณ {{signal}} ก่อนที่เซิร์ฟเวอร์จะเริ่มต้น",
"server.spawnFailed": "ไม่สามารถเริ่มต้นไบนารี CLI ({{code}})",
"server.startupTimeout": "หมดเวลาเริ่มต้นเซิร์ฟเวอร์หลังจาก {{seconds}} วินาที",
"remote.connected": "Kilo Remote: เชื่อมต่อแล้ว",
"remote.connecting": "Kilo Remote: กำลังเชื่อมต่อ\u2026",
} as const
+3 -1
View File
@@ -1,5 +1,7 @@
export const dict = {
"server.processExited": "CLI işlemi sunucu başlamadan önce {{code}} koduyla çıktı",
"server.processExited": "CLI işlemi sunucu başlatılmadan önce {{code}} koduyla çıktı",
"server.processSignaled": "CLI işlemi sunucu başlatılmadan önce {{signal}} sinyali ile sonlandırıldı",
"server.spawnFailed": "CLI ikili dosyası başlatılamadı ({{code}})",
"server.startupTimeout": "{{seconds}} saniye sonra sunucu başlatma zaman aşımı",
"remote.connected": "Kilo Remote: Bağlandı",
"remote.connecting": "Kilo Remote: Bağlanıyor\u2026",
+3 -1
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "Процес CLI завершився з кодом {{code}} до запуску сервера",
"server.startupTimeout": "Час очікування запуску сервера вичерпано після {{seconds}} секунд",
"server.processSignaled": "Процес CLI був завершений сигналом {{signal}} до запуску сервера",
"server.spawnFailed": "Не вдалося запустити двійковий файл CLI ({{code}})",
"server.startupTimeout": "Час очікування запуску сервера минув через {{seconds}} секунд",
"remote.connected": "Kilo Remote: Підключено",
"remote.connecting": "Kilo Remote: Підключення\u2026",
} as const
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "在服务器启动之前,CLI 进程已退出,代码 {{code}}",
"server.startupTimeout": "服务器启动在 {{seconds}} 秒后超时",
"server.processExited": "CLI 进程在服务器启动前以代码 {{code}} 退出",
"server.processSignaled": "CLI 进程在服务器启动前被信号 {{signal}} 终止",
"server.spawnFailed": "生成 CLI 二进制文件失败 ({{code}})",
"server.startupTimeout": "{{seconds}} 秒后服务器启动超时",
"remote.connected": "Kilo Remote: 已连接",
"remote.connecting": "Kilo Remote: 正在连接\u2026",
} as const
+4 -2
View File
@@ -1,6 +1,8 @@
export const dict = {
"server.processExited": "在伺服器啟動之前,CLI 處理程序已退出,代碼 {{code}}",
"server.startupTimeout": "伺服器啟動在 {{seconds}} 秒後逾時",
"server.processExited": "CLI 程序在伺服器啟動前以代碼 {{code}} 退出",
"server.processSignaled": "CLI 程序在伺服器啟動前被信號 {{signal}} 終止",
"server.spawnFailed": "生成 CLI 二進位檔案失敗 ({{code}})",
"server.startupTimeout": "{{seconds}} 秒後伺服器啟動逾時",
"remote.connected": "Kilo Remote: 已連線",
"remote.connecting": "Kilo Remote: 正在連線\u2026",
} as const
@@ -17,7 +17,7 @@ export interface ServerInstance {
const STARTUP_TIMEOUT_SECONDS = 30
type WorkspaceFolderLike = { uri: { fsPath: string } }
type ServerExitListener = (code: number | null) => void
type ServerExitListener = (code: number | null, signal: NodeJS.Signals | null) => void
export function resolveServerCwd(folders: readonly WorkspaceFolderLike[] | undefined, storage: string): string {
return folders?.[0]?.uri.fsPath ?? storage
@@ -182,25 +182,36 @@ export class ServerManager {
stderrLines.push(errorOutput)
})
serverProcess.on("error", (error) => {
console.error("[Kilo New] ServerManager: ❌ Process error:", error)
serverProcess.on("error", (err: NodeJS.ErrnoException) => {
console.error("[Kilo New] ServerManager: ❌ Process error:", err)
if (!resolved) {
reject(error)
const spawnErr = err as NodeJS.ErrnoException & { spawnargs?: string[] }
const code = err.code || err.name || "UNKNOWN"
const header = t("server.spawnFailed", { code })
const lines = [
`Error: ${err.message}`,
...(err.code ? [`Code: ${err.code}`] : []),
...(err.errno !== undefined ? [`Errno: ${err.errno}`] : []),
...(err.syscall ? [`Syscall: ${err.syscall}`] : []),
...(err.path ? [`Path: ${err.path}`] : []),
...(Array.isArray(spawnErr.spawnargs) ? [`Spawn args: ${JSON.stringify(spawnErr.spawnargs)}`] : []),
]
const { userMessage, userDetails } = toErrorMessage(header, [...lines, ...stderrLines], cliPath)
reject(new ServerStartupError(userMessage, userDetails))
}
})
serverProcess.on("exit", (code) => {
console.log("[Kilo New] ServerManager: 🛑 Process exited with code:", code)
serverProcess.on("exit", (code, signal) => {
console.warn("[Kilo New] ServerManager: 🛑 Process exited:", { code, signal })
if (this.instance?.process === serverProcess) {
this.instance = null
this.onExit?.(code)
this.onExit?.(code, signal)
}
if (!resolved) {
const { userMessage, userDetails } = toErrorMessage(
t("server.processExited", { code: code ?? "null" }),
stderrLines,
cliPath,
)
const msg = signal
? t("server.processSignaled", { signal })
: t("server.processExited", { code: code ?? "unknown" })
const { userMessage, userDetails } = toErrorMessage(msg, stderrLines, cliPath)
reject(new ServerStartupError(userMessage, userDetails))
}
})
@@ -46,7 +46,10 @@ function run(args) {
const error = Ref()
const url = $.NSURL.fileURLWithPath(args[0])
const recorder = $.AVAudioRecorder.alloc.initWithURLSettingsError(url, settings, error)
if (!recorder || !recorder.prepareToRecord || !recorder.record) throw new Error("Could not start recording")
if (!recorder || !recorder.prepareToRecord || !recorder.record) {
const description = error[0] && error[0].localizedDescription
throw new Error(description ? description.js : "Could not start recording")
}
console.log("ready")
$.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
recorder.stop
@@ -32,6 +32,19 @@ async function assertRowContained(row: Locator, card: Locator, label: string) {
)
}
async function assertTooltipFitsViewport(content: Locator, label: string, page: Page) {
const tipBox = await content.boundingBox()
const viewport = page.viewportSize()!
expect(tipBox, `${label}: tooltip bounding box`).not.toBeNull()
// Kobalte's PopperRoot defaults to overflowPadding: 8, so the floating
// tooltip is allowed to extend up to 8px past each viewport edge before
// the shift middleware stops nudging it.
expect(tipBox!.x, `${label}: tooltip left edge inside viewport`).toBeGreaterThanOrEqual(-9)
expect(tipBox!.x + tipBox!.width, `${label}: tooltip right edge inside viewport`).toBeLessThanOrEqual(
viewport.width + 9,
)
}
test.describe("skills settings responsive layout", () => {
test("folder-path and URL rows stay contained and the × button remains visible at a narrow viewport", async ({
page,
@@ -73,6 +86,7 @@ test.describe("skills settings responsive layout", () => {
await trigger.hover()
const content = page.locator('[data-component="tooltip"]').filter({ hasText: seeded })
await expect(content, `Kilo Tooltip exposes full path on hover: ${seeded}`).toBeVisible()
await assertTooltipFitsViewport(content, `path tooltip "${seeded}"`, page)
}
for (const seeded of [SEEDED_URL, SEEDED_URL_2]) {
@@ -99,6 +113,7 @@ test.describe("skills settings responsive layout", () => {
await trigger.hover()
const content = page.locator('[data-component="tooltip"]').filter({ hasText: seeded })
await expect(content, `Kilo Tooltip exposes full URL on hover: ${seeded}`).toBeVisible()
await assertTooltipFitsViewport(content, `URL tooltip "${seeded}"`, page)
}
for (const [label, card] of [
@@ -33,7 +33,9 @@ describe("AgentManagerOrchestrationBridge", () => {
fs.rmSync(root, { recursive: true, force: true })
})
function harness() {
function harness(
overrides?: Partial<Parameters<(typeof AgentManagerOrchestrationBridge.prototype)["constructor"]>[1]>,
) {
const replies: unknown[] = []
const rejections: unknown[] = []
const lists = new Map<string, AgentManagerRequest[]>()
@@ -49,8 +51,8 @@ describe("AgentManagerOrchestrationBridge", () => {
const push = mock(() => undefined)
const client = {
session: {
get: mock(async () => ({
data: { id: "ses_target", directory: dir, title: "Target" } as Session,
get: mock(async ({ sessionID, directory }: { sessionID?: string; directory?: string }) => ({
data: { id: sessionID ?? "ses_target", directory: directory ?? dir, title: "Target" } as Session,
})),
status: mock(async () => ({ data: {} })),
promptAsync,
@@ -100,17 +102,17 @@ describe("AgentManagerOrchestrationBridge", () => {
getClient: () => client,
}
const bridge = new AgentManagerOrchestrationBridge(connection as never, {
root: () => root,
ready: async () => state,
state: () => state,
stats: async () => {
root: (dir) => (overrides?.root ? overrides.root(dir) : root),
ready: async (dir) => (overrides?.ready ? overrides.ready(dir) : state),
state: (dir) => (overrides?.state ? overrides.state(dir) : state),
stats: async (dir) => {
statsCalls.push(1)
return { worktrees: [] }
return overrides?.stats ? overrides.stats(dir) : { worktrees: [] }
},
prs: () => new Map(),
push,
managed: (id) => managed.has(id),
close,
prs: (dir) => (overrides?.prs ? overrides.prs(dir) : new Map()),
push: (dir) => (overrides?.push ? overrides.push(dir) : push()),
managed: (id, dir) => (overrides?.managed ? overrides.managed(id, dir) : managed.has(id)),
close: async (id, dir) => (overrides?.close ? overrides.close(id, dir) : close(id, dir)),
log: () => undefined,
})
const request = (value: AgentManagerRequest, directory = root) =>
@@ -195,7 +197,7 @@ describe("AgentManagerOrchestrationBridge", () => {
await waitFor(() => test.replies.length === 2)
expect(test.close).toHaveBeenCalledTimes(1)
expect(test.close).toHaveBeenCalledWith("ses_target")
expect(test.close).toHaveBeenCalledWith("ses_target", root)
expect(test.replies).toEqual([
{
requestID: "amr_stop",
@@ -294,7 +296,7 @@ describe("AgentManagerOrchestrationBridge", () => {
await waitFor(() => test.replies.length === 1)
expect(state.getSession("ses_live")).toBeUndefined()
expect(test.close).toHaveBeenCalledWith("ses_live")
expect(test.close).toHaveBeenCalledWith("ses_live", root)
expect(test.replies[0]).toEqual({
requestID: "amr_stop_live",
directory: root,
@@ -389,4 +391,39 @@ describe("AgentManagerOrchestrationBridge", () => {
expect(test.promptAsync).toHaveBeenCalledTimes(1)
test.bridge.dispose()
})
it("handles requests for secondary project directories in multi-project mode", async () => {
const secondaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "am-orchestration-secondary-"))
fs.mkdirSync(path.join(secondaryRoot, ".kilo"), { recursive: true })
const secondaryState = new WorktreeStateManager(secondaryRoot, () => undefined)
secondaryState.addSession("ses_secondary", null)
const test = harness({
root: (d) => (d === secondaryRoot ? secondaryRoot : root),
ready: async (d) => (d === secondaryRoot ? secondaryState : state),
state: (d) => (d === secondaryRoot ? secondaryState : state),
})
test.request(
{
id: "amr_secondary",
sessionID: "ses_caller",
operation: "prompt",
targetSessionID: "ses_secondary",
prompt: "Hello from secondary",
},
secondaryRoot,
)
await waitFor(() => test.replies.length === 1)
expect(test.promptAsync).toHaveBeenCalledTimes(1)
expect(test.replies[0]).toEqual({
requestID: "amr_secondary",
directory: secondaryRoot,
result: { operation: "prompt", sessionID: "ses_secondary", delivered: true },
})
test.bridge.dispose()
await secondaryState.flush()
fs.rmSync(secondaryRoot, { recursive: true, force: true })
})
})
@@ -202,6 +202,25 @@ describe("Agent Manager orchestration domain", () => {
)
})
it("waits for a busy managed session to become idle before prompting", async () => {
const managed = state.addWorktree({ branch: "fix/wait", path: worktree, parentBranch: "main" })
state.addSession("ses_wait", managed.id)
let calls = 0
const promptAsync = mock(async () => ({ data: undefined }))
const client = {
session: {
get: mock(async () => ({ data: { id: "ses_wait", directory: worktree, title: "Wait" } as Session })),
status: mock(async () => ({ data: calls++ === 0 ? { ses_wait: { type: "busy" } } : {} })),
promptAsync,
},
} as unknown as KiloClient
await prompt({ client, root, state, sessionID: "ses_wait", text: "Continue", messageID: "amr_wait" })
expect(client.session.status).toHaveBeenCalledTimes(2)
expect(promptAsync).toHaveBeenCalledTimes(1)
})
it("rejects unknown, stale, cross-workspace, and busy targets", async () => {
const managed = state.addWorktree({ branch: "fix/errors", path: worktree, parentBranch: "main" })
state.addSession("ses_target", managed.id)
@@ -231,7 +250,15 @@ describe("Agent Manager orchestration domain", () => {
data: { ses_target: { type: "busy" } },
}))
await expect(
prompt({ client, root, state, sessionID: "ses_target", text: "Continue", messageID: "amr_busy" }),
prompt({
client,
root,
state,
sessionID: "ses_target",
text: "Continue",
messageID: "amr_busy",
idleTimeoutMs: 0,
}),
).rejects.toMatchObject({
code: "unavailable_session",
} satisfies Partial<OrchestrationError>)
@@ -0,0 +1,31 @@
import { describe, expect, it } from "bun:test"
import { routeToolRequest } from "../../src/agent-manager/tool-project"
describe("Agent Manager tool project routing", () => {
it("routes by the event directory before any explicit project id", () => {
const secondary = { id: "prj-secondary" }
const request = routeToolRequest({ requestID: "am-1", projectId: "prj-active", mode: "worktree" }, "/secondary", {
byDirectory: (dir) => (dir === "/secondary" ? secondary : undefined),
usable: () => ({ id: "prj-active" }),
})
expect(request.owner).toBe(secondary)
expect(request.request).toEqual({
requestID: "am-1",
projectId: "prj-secondary",
mode: "worktree",
directory: "/secondary",
})
})
it("uses an explicit usable project when no event directory is available", () => {
const project = { id: "prj-secondary" }
const request = routeToolRequest({ requestID: "am-2", projectId: "prj-secondary", mode: "local" }, undefined, {
byDirectory: () => undefined,
usable: (id) => (id === project.id ? project : undefined),
})
expect(request.owner).toBe(project)
expect(request.request.projectId).toBe("prj-secondary")
})
})
@@ -295,6 +295,34 @@ describe("toErrorMessage", () => {
const result = toErrorMessage("startup failed", ["some output"])
expect(result.error).toBe("startup failed")
})
it("formats structured spawn error details with syscall, errno, and args", () => {
const spawnLines = [
"Error: spawn UNKNOWN",
"Code: UNKNOWN",
"Errno: -86",
"Syscall: spawn",
"Path: /path/to/bin/kilo",
'Spawn args: ["serve","--port","0"]',
]
const result = toErrorMessage("Failed to spawn CLI binary (UNKNOWN)", spawnLines, "/path/to/bin/kilo")
expect(result.userMessage).toBe("spawn UNKNOWN")
expect(result.userDetails).toContain("CLI path: /path/to/bin/kilo")
expect(result.userDetails).toContain("Failed to spawn CLI binary (UNKNOWN)")
expect(result.userDetails).toContain("Syscall: spawn")
expect(result.userDetails).toContain("Errno: -86")
})
it("handles signal termination without stderr lines cleanly", () => {
const result = toErrorMessage(
"CLI process terminated by signal SIGSEGV before server started",
[],
"/path/to/bin/kilo",
)
expect(result.userMessage).toBe("CLI process terminated by signal SIGSEGV before server started")
expect(result.userDetails).toContain("CLI path: /path/to/bin/kilo")
expect(result.userDetails).toContain("CLI process terminated by signal SIGSEGV before server started")
})
})
describe("server workspace helpers", () => {
@@ -19,6 +19,7 @@ describe("macCaptureArgs", () => {
expect(args[3]).toContain("numberWithDouble(16000), $.AVSampleRateKey")
expect(args[3]).toContain("numberWithInt(1), $.AVNumberOfChannelsKey")
expect(args[3]).toContain("numberWithInt(24000), $.AVEncoderBitRateKey")
expect(args[3]).toContain("error[0] && error[0].localizedDescription")
expect(args[3]).toContain('console.log("ready")')
})
})
@@ -903,7 +903,7 @@ const AgentBehaviourTab: Component = () => {
"border-bottom": index() < skillPaths().length - 1 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<Tooltip value={path} class="settings-skills-row-trigger">
<Tooltip value={path} class="settings-skills-row-trigger" contentClass="settings-skills-tooltip-content">
<span
style={{
width: "100%",
@@ -960,7 +960,7 @@ const AgentBehaviourTab: Component = () => {
"border-bottom": index() < skillUrls().length - 1 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<Tooltip value={url} class="settings-skills-row-trigger">
<Tooltip value={url} class="settings-skills-row-trigger" contentClass="settings-skills-tooltip-content">
<span
style={{
width: "100%",
@@ -146,3 +146,9 @@
flex: 1;
min-width: 0;
}
/* Wrap long skill paths/URLs within the shared 320px tooltip width. */
.settings-skills-tooltip-content {
white-space: normal;
overflow-wrap: anywhere;
}
@@ -53,7 +53,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@kilocode/AgentManager") {}
export function layer(timeout: Duration.Input = "10 seconds") {
export function layer(timeout: Duration.Input = "60 seconds") {
return Layer.effect(
Service,
Effect.gen(function* () {
@@ -1,4 +1,4 @@
import { readFileSync, statSync } from "node:fs"
import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs"
import os from "node:os"
import path from "node:path"
import { Effect, Semaphore } from "effect"
@@ -194,6 +194,37 @@ function isolated(ctx: InstanceContext) {
return linked(path.resolve(ctx.directory), path.resolve(ctx.worktree))
}
function canonical(dir: string) {
try {
return realpathSync.native(dir)
} catch {
return path.resolve(dir)
}
}
function ancestor(value: string, target: string) {
const relative = path.relative(canonical(value), canonical(target))
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
}
function accessible(dir: string) {
try {
accessSync(dir, constants.R_OK)
return true
} catch {
return false
}
}
function filterWritable(ctx: InstanceContext, values: readonly string[]) {
const list = values.filter(accessible)
if (!isolated(ctx)) return list
// A nested macOS sandbox cannot reliably canonicalize an inherited writable
// ancestor of the linked worktree. The active worktree is already writable;
// keep unrelated explicit paths, but do not widen it back to the repository.
return list.filter((value) => !ancestor(value, ctx.directory))
}
export function profile(
ctx: InstanceContext,
mode: Profile["network"]["mode"] = "deny",
@@ -215,7 +246,7 @@ export function profile(
Global.Path.bin,
Global.Path.log,
Global.Path.repos,
...(extraWritable ?? []),
...filterWritable(ctx, extraWritable ?? []),
].map(root)
return {
filesystem: {
@@ -130,6 +130,17 @@ describe("sandbox policy", () => {
expect(actual).not.toContain(dirs.b)
})
test("drops inherited writable ancestors for a managed worktree", async () => {
await using tmp = await fixture()
const dirs = tmp.extra
const policy = profile(context(dirs.a, dirs.main, dirs), "deny", [dirs.main, dirs.approved])
const paths = policy.filesystem.allowWrite.map((rule) => rule.path)
expect(paths).not.toContain(dirs.main)
expect(paths).toContain(dirs.approved)
expect(paths).toContain(dirs.a)
})
posix("fails closed when a worktree marker cannot be resolved", async () => {
await using tmp = await fixture()
const dirs = tmp.extra