mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge remote-tracking branch 'origin/main' into feat/show-approval-reason-outside-workspace-reads-and-writes
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+2786
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user