Merge branch 'main' into fix/disable-suggest-in-non-interactive-runs

This commit is contained in:
Christiaan Arnoldus
2026-08-07 19:52:45 +02:00
committed by GitHub
68 changed files with 3296 additions and 119 deletions
+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
---
Show the permission approval reason for reads and writes outside the workspace, matching other tools
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Surface process exit signals and structured spawn failure details in server startup diagnostics
@@ -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

@@ -508,8 +508,8 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
}
/* "why was this allowed" line inside a tool's expanded body. Styled like
[data-component="tool-hint"] (muted, italic) so it reads as ambient
context rather than a call to action, and recedes the way reasoning text does. */
[data-component="tool-hint"] (muted) so it reads as ambient context rather
than a call to action, and recedes the way reasoning text does. */
[data-slot="tool-approval-line"] {
display: flex;
flex-wrap: wrap;
@@ -518,7 +518,6 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
padding: 4px 0 6px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: italic;
line-height: var(--line-height-normal);
color: var(--text-weak);
opacity: 0.9;
@@ -528,6 +527,11 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
color: var(--text-weak);
}
[data-slot="tool-approval-decision"] {
font-weight: var(--font-weight-medium);
color: var(--text-strong);
}
[data-slot="tool-approval-rule"] {
font-family: var(--font-family-mono);
}
@@ -48,7 +48,7 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { ToolApprovalProvider, resolveToolApproval } from "./tool-approval"
import { ToolApprovalProvider, resolveToolApproval, useToolApproval } from "./tool-approval"
export { ToolApprovalProvider, resolveToolApproval, ToolApprovalVisibilityProvider } from "./tool-approval"
import { GrowBox } from "./grow-box"
import { COLLAPSIBLE_SPRING } from "./motion"
@@ -1888,10 +1888,13 @@ ToolRegistry.register({
const pending = createMemo(() => busy(props.status))
const images = createMemo(() => (props.attachments ?? []).filter((f) => f.mime.startsWith("image/") && f.url))
const preview = (url: string, alt?: string) => dialog.show(() => <ImagePreview src={url} alt={alt} />)
// Read is high-frequency and low-risk, so details stay hidden unless the target was outside
// the workspace, in which case the approval reason explains what looks like an "agent escape".
const approval = useToolApproval()
return (
<>
<BasicTool
hideDetails
hideDetails={!approval()?.approval.outsideWorkspace}
{...props}
icon="glasses"
onSubtitleClick={
@@ -54,4 +54,28 @@ describe("resolveToolApproval", () => {
expect(out?.source).toBe("ui.approval.source.agent(agent=code)")
expect(out?.rule).toBeUndefined()
})
test("adds the outsideWorkspace text with just the filename when a path is known", () => {
const approval = {
source: "agent" as const,
agent: "code",
outsideWorkspace: true,
outsideWorkspacePath: "/etc/secrets/hello.txt",
}
const out = resolveToolApproval({ approval }, t)
expect(out?.outsideWorkspace).toBe("ui.approval.outsideWorkspace(file=hello.txt)")
})
test("omits the outsideWorkspace text for an ordinary in-workspace approval", () => {
const approval = { source: "agent" as const, agent: "code" }
const out = resolveToolApproval({ approval }, t)
expect(out?.outsideWorkspace).toBeUndefined()
})
test("omits the outsideWorkspace text when outsideWorkspace is set but no path is known", () => {
// e.g. a bash command scanning multiple external directories has no single filepath to show.
const approval = { source: "agent" as const, agent: "code", outsideWorkspace: true }
const out = resolveToolApproval({ approval }, t)
expect(out?.outsideWorkspace).toBeUndefined()
})
})
@@ -1,4 +1,5 @@
import { createContext, useContext, Show, type Accessor, type ParentProps } from "solid-js"
import { getFilename } from "@opencode-ai/core/util/path"
import { Icon } from "./icon"
/**
@@ -12,6 +13,10 @@ export type ToolApproval = {
source: "agent" | "global" | "project" | "yolo" | "session" | "manual" | "default"
agent?: string
rule?: { permission: string; pattern: string; action: string }
/** True when the tool call's target path was outside the workspace/worktree. */
outsideWorkspace?: boolean
/** The target file path, when known, for display as a filename next to the note above. */
outsideWorkspacePath?: string
}
/** Pre-resolved, localized text plus the raw approval, supplied by the caller. */
@@ -20,6 +25,7 @@ export type ToolApprovalDisplay = {
decision: string
source?: string
rule?: string
outsideWorkspace?: string
}
const SOURCE_KEYS = ["agent", "global", "project", "yolo", "session", "manual", "default"] as const
@@ -82,11 +88,16 @@ export function resolveToolApproval(
rule && !(rule.permission === "*" && rule.pattern === "*")
? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern })
: undefined
// Only worth calling out when we know which file it was; a bare "outside your workspace" note
// without a filename (e.g. a bash command touching several directories) isn't actionable.
const filename = approval.outsideWorkspacePath ? getFilename(approval.outsideWorkspacePath) : undefined
return {
approval,
decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"),
source: sourceText(),
rule: ruleText,
outsideWorkspace:
approval.outsideWorkspace && filename ? t("ui.approval.outsideWorkspace", { file: filename }) : undefined,
}
}
@@ -101,6 +112,9 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
<Show when={props.display.source}>{(text) => <span data-slot="tool-approval-source">{text()}</span>}</Show>
<Show when={props.display.rule}>{(text) => <span data-slot="tool-approval-rule">{text()}</span>}</Show>
</Show>
<Show when={props.display.outsideWorkspace}>
{(text) => <span data-slot="tool-approval-outside-workspace">{text()}</span>}
</Show>
</div>
)
}
@@ -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,
}),
})
}
}
@@ -73,6 +73,7 @@ function slimEdit(state: Record<string, unknown>): Record<string, unknown> {
}
}
if (meta.diagnostics) result.diagnostics = meta.diagnostics
if (meta.approval) result.approval = meta.approval
next.metadata = result
return next
}
@@ -84,6 +85,7 @@ function slimPatch(state: Record<string, unknown>): Record<string, unknown> {
if (isObj(meta)) {
const slim: Record<string, unknown> = {}
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
if (meta.approval) slim.approval = meta.approval
if (Array.isArray(meta.files)) {
slim.files = (meta.files as Record<string, unknown>[]).map((f) => {
const diff = patch(f.patch) ?? patch(f.diff)
@@ -115,6 +117,7 @@ function slimMultiedit(state: Record<string, unknown>): Record<string, unknown>
if (isObj(meta)) {
const slim: Record<string, unknown> = {}
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
if (meta.approval) slim.approval = meta.approval
if (Array.isArray(meta.results)) {
slim.results = (meta.results as Record<string, unknown>[]).map((r) => {
const rs: Record<string, unknown> = {}
@@ -149,6 +152,7 @@ function slimWrite(state: Record<string, unknown>): Record<string, unknown> {
if (meta.filepath) slim.filepath = meta.filepath
if (meta.exists !== undefined) slim.exists = meta.exists
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
if (meta.approval) slim.approval = meta.approval
const fd = meta.filediff
if (isObj(fd)) {
slim.filediff = {
@@ -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))
}
})
@@ -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", () => {
@@ -30,6 +30,10 @@ const BIG = "x".repeat(200_000) // 200 KB — typical file content size
const DIAG = [
{ range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } }, message: "err", severity: 1 },
]
// Regression for #13001: slimmers used to rebuild `metadata` from an explicit allowlist that
// didn't include `approval`, silently dropping the auto-approval reason (and the
// outside-workspace note) before it ever reached the webview.
const APPROVAL = { source: "agent", agent: "code", outsideWorkspace: true, outsideWorkspacePath: "/tmp/a.ts" }
// ---------------------------------------------------------------------------
// Tests
@@ -96,6 +100,7 @@ describe("slimPart", () => {
diff: BIG,
filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 3, deletions: 1 },
diagnostics: { "/a.ts": DIAG },
approval: APPROVAL,
},
})
@@ -103,7 +108,7 @@ describe("slimPart", () => {
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
})
it("keeps filediff counts and diagnostics", () => {
it("keeps filediff counts, diagnostics, and approval", () => {
const slim = slimPart(heavy) as Record<string, any>
const meta = slim.state.metadata
expect(meta.filediff.file).toBe("/a.ts")
@@ -111,6 +116,7 @@ describe("slimPart", () => {
expect(meta.filediff.additions).toBe(3)
expect(meta.filediff.deletions).toBe(1)
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
expect(meta.approval).toEqual(APPROVAL)
})
it("keeps output and input intact", () => {
@@ -176,6 +182,7 @@ describe("slimPart", () => {
},
],
diagnostics: { "/a.ts": DIAG },
approval: APPROVAL,
},
})
@@ -183,7 +190,7 @@ describe("slimPart", () => {
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
})
it("keeps file summary fields and diagnostics", () => {
it("keeps file summary fields, diagnostics, and approval", () => {
const slim = slimPart(heavy) as Record<string, any>
const meta = slim.state.metadata
expect(meta.files[0].filePath).toBe("/a.ts")
@@ -193,6 +200,7 @@ describe("slimPart", () => {
expect(meta.files[0].additions).toBe(5)
expect(meta.files[1].type).toBe("add")
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
expect(meta.approval).toEqual(APPROVAL)
})
it("drops unknown heavy metadata fields", () => {
@@ -266,6 +274,7 @@ describe("slimPart", () => {
},
{ filediff: { file: "/b.ts", before: BIG, after: BIG, additions: 2, deletions: 0 }, diagnostics: {} },
],
approval: APPROVAL,
},
})
@@ -273,7 +282,7 @@ describe("slimPart", () => {
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
})
it("keeps filediff counts and per-result diagnostics", () => {
it("keeps filediff counts, per-result diagnostics, and approval", () => {
const slim = slimPart(heavy) as Record<string, any>
const meta = slim.state.metadata
expect(meta.results[0].filediff.file).toBe("/a.ts")
@@ -282,6 +291,7 @@ describe("slimPart", () => {
expect(meta.results[0].diagnostics).toEqual({ "/a.ts": DIAG })
expect(meta.results[1].filediff.file).toBe("/b.ts")
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
expect(meta.approval).toEqual(APPROVAL)
})
it("drops unknown heavy metadata fields", () => {
@@ -318,6 +328,7 @@ describe("slimPart", () => {
diff: BIG,
filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 100, deletions: 0 },
diagnostics: { "/a.ts": DIAG },
approval: APPROVAL,
},
})
@@ -325,7 +336,7 @@ describe("slimPart", () => {
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
})
it("keeps filepath, exists, filediff counts, diagnostics", () => {
it("keeps filepath, exists, filediff counts, diagnostics, and approval", () => {
const slim = slimPart(heavy) as Record<string, any>
const meta = slim.state.metadata
expect(meta.filepath).toBe("/a.ts")
@@ -335,6 +346,7 @@ describe("slimPart", () => {
expect(meta.filediff.additions).toBe(100)
expect(meta.filediff.deletions).toBe(0)
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
expect(meta.approval).toEqual(APPROVAL)
})
it("drops unknown heavy metadata fields", () => {
+1
View File
@@ -297,6 +297,7 @@ export const dict = {
"ui.approval.source.yolo": "بواسطة وضع الموافقة التلقائية (YOLO)",
"ui.approval.source.session": "بواسطة قاعدة موافقة تلقائية للجلسة",
"ui.approval.source.default": "افتراضيًا",
"ui.approval.outsideWorkspace": "(خارج مساحة العمل: {{file}})",
"session.tab.review": "مراجعة",
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
+1
View File
@@ -307,6 +307,7 @@ export const dict = {
"ui.approval.source.yolo": "pelo modo de aprovação automática (YOLO)",
"ui.approval.source.session": "por uma regra de aprovação automática da sessão",
"ui.approval.source.default": "por padrão",
"ui.approval.outsideWorkspace": "(fora do seu espaço de trabalho: {{file}})",
"session.tab.review": "Revisão",
"session.review.filesChanged": "{{count}} Arquivos Alterados",
+1
View File
@@ -305,6 +305,7 @@ export const dict = {
"ui.approval.source.yolo": "režimom automatskog odobravanja (YOLO)",
"ui.approval.source.session": "pravilom automatskog odobravanja sesije",
"ui.approval.source.default": "podrazumevano",
"ui.approval.outsideWorkspace": "(izvan vašeg radnog prostora: {{file}})",
"session.tab.review": "Pregled",
"session.review.filesChanged": "Izmijenjeno {{count}} datoteka",
+1
View File
@@ -304,6 +304,7 @@ export const dict = {
"ui.approval.source.yolo": "af automatisk godkendelse (YOLO)",
"ui.approval.source.session": "af en session-autogodkendelsesregel",
"ui.approval.source.default": "som standard",
"ui.approval.outsideWorkspace": "(uden for dit arbejdsområde: {{file}})",
"session.tab.review": "Gennemgang",
"session.review.filesChanged": "{{count}} Filer ændret",
@@ -313,6 +313,7 @@ export const dict = {
"ui.approval.source.yolo": "durch den Auto-Genehmigungsmodus (YOLO)",
"ui.approval.source.session": "durch eine Sitzungs-Auto-Genehmigungsregel",
"ui.approval.source.default": "standardmäßig",
"ui.approval.outsideWorkspace": "(außerhalb deines Arbeitsbereichs: {{file}})",
"session.tab.review": "Überprüfung",
"session.review.filesChanged": "{{count}} Dateien geändert",
@@ -302,6 +302,7 @@ export const dict = {
"ui.approval.source.yolo": "by auto-approve (YOLO) mode",
"ui.approval.source.session": "by a session auto-approve rule",
"ui.approval.source.default": "by default",
"ui.approval.outsideWorkspace": "(outside your workspace: {{file}})",
"session.tab.review": "Review",
"session.review.filesChanged": "{{count}} Files Changed",
+1
View File
@@ -308,6 +308,7 @@ export const dict = {
"ui.approval.source.yolo": "por el modo de aprobación automática (YOLO)",
"ui.approval.source.session": "por una regla de aprobación automática de sesión",
"ui.approval.source.default": "de forma predeterminada",
"ui.approval.outsideWorkspace": "(fuera de tu espacio de trabajo: {{file}})",
"session.tab.review": "Revisión",
"session.review.filesChanged": "{{count}} Archivos Cambiados",
+1
View File
@@ -302,6 +302,7 @@ export const dict = {
"ui.approval.source.yolo": "توسط حالت تأیید خودکار (YOLO)",
"ui.approval.source.session": "توسط قانون تأیید خودکار جلسه",
"ui.approval.source.default": "به‌طور پیش‌فرض",
"ui.approval.outsideWorkspace": "(خارج از فضای کاری شما: {{file}})",
"session.tab.review": "بررسی",
"session.review.filesChanged": "{{count}} فایل تغییر یافته",
+1
View File
@@ -307,6 +307,7 @@ export const dict = {
"ui.approval.source.yolo": "par le mode d'approbation automatique (YOLO)",
"ui.approval.source.session": "par une règle d'approbation automatique de session",
"ui.approval.source.default": "par défaut",
"ui.approval.outsideWorkspace": "(hors de votre espace de travail : {{file}})",
"session.tab.review": "Revue",
"session.review.filesChanged": "{{count}} fichiers modifiés",
+1
View File
@@ -219,6 +219,7 @@ export const dict = {
"ui.approval.source.yolo": "dalla modalità di approvazione automatica (YOLO)",
"ui.approval.source.session": "da una regola di approvazione automatica della sessione",
"ui.approval.source.default": "per impostazione predefinita",
"ui.approval.outsideWorkspace": "(fuori dall'area di lavoro: {{file}})",
"session.tab.review": "Revisione",
"session.review.filesChanged": "{{count}} file modificati",
"session.review.loadingChanges": "Caricamento modifiche...",
+1
View File
@@ -304,6 +304,7 @@ export const dict = {
"ui.approval.source.yolo": "自動承認(YOLO)モードによって",
"ui.approval.source.session": "セッションの自動承認ルールによって",
"ui.approval.source.default": "デフォルトで",
"ui.approval.outsideWorkspace": "(ワークスペース外:{{file}}",
"session.tab.review": "レビュー",
"session.review.filesChanged": "{{count}} ファイル変更",
+1
View File
@@ -305,6 +305,7 @@ export const dict = {
"ui.approval.source.yolo": "자동 승인(YOLO) 모드에 의해",
"ui.approval.source.session": "세션 자동 승인 규칙에 의해",
"ui.approval.source.default": "기본값으로",
"ui.approval.outsideWorkspace": "(작업 영역 외부: {{file}})",
"session.tab.review": "검토",
"session.review.filesChanged": "{{count}}개 파일 변경됨",
+1
View File
@@ -308,6 +308,7 @@ export const dict = {
"ui.approval.source.yolo": "door de automatische goedkeuringsmodus (YOLO)",
"ui.approval.source.session": "door een sessie-automatische-goedkeuringsregel",
"ui.approval.source.default": "standaard",
"ui.approval.outsideWorkspace": "(buiten je werkruimte: {{file}})",
"session.tab.review": "Beoordelen",
"session.review.filesChanged": "{{count}} bestanden gewijzigd",
+1
View File
@@ -311,6 +311,7 @@ export const dict = {
"ui.approval.source.yolo": "av automatisk godkjenning (YOLO)",
"ui.approval.source.session": "av en økt-autogodkjenningsregel",
"ui.approval.source.default": "som standard",
"ui.approval.outsideWorkspace": "(utenfor arbeidsområdet ditt: {{file}})",
"session.tab.review": "Gjennomgang",
"session.review.filesChanged": "{{count}} filer endret",
+1
View File
@@ -305,6 +305,7 @@ export const dict = {
"ui.approval.source.yolo": "przez tryb automatycznego zatwierdzania (YOLO)",
"ui.approval.source.session": "przez regułę automatycznego zatwierdzania sesji",
"ui.approval.source.default": "domyślnie",
"ui.approval.outsideWorkspace": "(poza obszarem roboczym: {{file}})",
"session.tab.review": "Przegląd",
"session.review.filesChanged": "Zmieniono {{count}} plików",
+1
View File
@@ -303,6 +303,7 @@ export const dict = {
"ui.approval.source.yolo": "режимом автоодобрения (YOLO)",
"ui.approval.source.session": "правилом автоодобрения сессии",
"ui.approval.source.default": "по умолчанию",
"ui.approval.outsideWorkspace": "(за пределами вашей рабочей области: {{file}})",
"session.tab.review": "Обзор",
"session.review.filesChanged": "{{count}} файлов изменено",
+1
View File
@@ -302,6 +302,7 @@ export const dict = {
"ui.approval.source.yolo": "โดยโหมดอนุมัติอัตโนมัติ (YOLO)",
"ui.approval.source.session": "โดยกฎอนุมัติอัตโนมัติของเซสชัน",
"ui.approval.source.default": "ตามค่าเริ่มต้น",
"ui.approval.outsideWorkspace": "(นอกพื้นที่ทำงานของคุณ: {{file}})",
"session.tab.review": "ตรวจสอบ",
"session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง",
+1
View File
@@ -303,6 +303,7 @@ export const dict = {
"ui.approval.source.yolo": "otomatik onay (YOLO) modu tarafından",
"ui.approval.source.session": "bir oturum otomatik onay kuralı tarafından",
"ui.approval.source.default": "varsayılan olarak",
"ui.approval.outsideWorkspace": "(çalışma alanınızın dışında: {{file}})",
"session.tab.review": "İnceleme",
"session.review.filesChanged": "{{count}} Dosya Değişti",
+1
View File
@@ -307,6 +307,7 @@ export const dict = {
"ui.approval.source.yolo": "режимом автосхвалення (YOLO)",
"ui.approval.source.session": "правилом автосхвалення сесії",
"ui.approval.source.default": "за замовчуванням",
"ui.approval.outsideWorkspace": "(за межами вашого робочого простору: {{file}})",
"session.tab.review": "Огляд",
"session.review.filesChanged": "{{count}} файлів змінено",
+1
View File
@@ -292,6 +292,7 @@ export const dict = {
"ui.approval.source.yolo": "由自动批准(YOLO)模式",
"ui.approval.source.session": "由会话自动批准规则",
"ui.approval.source.default": "默认",
"ui.approval.outsideWorkspace": "(工作区之外:{{file}}",
"session.tab.review": "审查",
"session.review.filesChanged": "{{count}} 个文件变更",
+1
View File
@@ -290,6 +290,7 @@ export const dict = {
"ui.approval.source.yolo": "由自動核准(YOLO)模式",
"ui.approval.source.session": "由工作階段自動核准規則",
"ui.approval.source.default": "預設",
"ui.approval.outsideWorkspace": "(工作區之外:{{file}}",
"session.tab.review": "審查",
"session.review.filesChanged": "{{count}} 個檔案變更",
@@ -27,6 +27,21 @@ export namespace PermissionProvenance {
agent?: string
/** The winning rule, omitted for manual replies and the ask fallback. */
rule?: { permission: string; pattern: string; action: Permission.Action }
/** True when the ask's target path was outside the workspace/worktree (an `external_directory` ask). */
outsideWorkspace?: boolean
/** The target file path, when the `external_directory` ask carried one, for display as a filename. */
outsideWorkspacePath?: string
}
/** The `filepath` an `external_directory` ask's metadata carries, if any (see `Tool.assertExternalDirectory`). */
export function filepathOf(metadata: Record<string, unknown> | undefined): string | undefined {
return typeof metadata?.filepath === "string" ? metadata.filepath : undefined
}
/** Tag an approval as outside-workspace when it answers an `external_directory` ask. */
export function tagOutsideWorkspace(approval: Approval, permission: string, path?: string): Approval {
if (permission !== "external_directory") return approval
return { ...approval, outsideWorkspace: true, ...(path ? { outsideWorkspacePath: path } : {}) }
}
export type Scope = "global" | "local"
@@ -71,13 +86,29 @@ export namespace PermissionProvenance {
* The approval is written once during `ask()`, but tools freely overwrite `state.metadata`
* during execution and on completion. Carry the prior `approval` onto the replacement unless
* the replacement sets its own.
*
* A file tool that crosses the workspace boundary issues *two* asks for one call: the generic
* `external_directory` ask first, then its own `read`/`write`/`edit` ask. Both write `approval`
* metadata, so the second ask's `outsideWorkspace` marker would otherwise clobber the first's
* even though `"approval" in next` is true. Merge that marker forward so it survives.
*/
export function carryApproval(
prev: Record<string, unknown> | undefined,
next: Record<string, unknown> | undefined,
) {
if (!next || !prev?.approval || "approval" in next) return next
return { ...next, approval: prev.approval }
if (!next) return next
const prior = prev?.approval as Approval | undefined
if (!("approval" in next)) return prior ? { ...next, approval: prior } : next
const current = next.approval as Approval | undefined
if (!prior?.outsideWorkspace || !current || current.outsideWorkspace) return next
return {
...next,
approval: {
...current,
outsideWorkspace: true,
...(prior.outsideWorkspacePath ? { outsideWorkspacePath: prior.outsideWorkspacePath } : {}),
},
}
}
/**
+22 -8
View File
@@ -97,18 +97,32 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
},
}).pipe(
// record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask
Effect.tap((approval) => input.processor.metadata(options.toolCallId, { metadata: { approval } })),
Effect.tap((approval) =>
input.processor.metadata(options.toolCallId, {
metadata: {
approval: PermissionProvenance.tagOutsideWorkspace(
approval,
req.permission,
PermissionProvenance.filepathOf(req.metadata),
),
},
}),
),
// record why the call was denied too, so JSON exports and clients can explain the denial
Effect.tapErrorTag("PermissionDeniedError", (err) =>
input.processor.metadata(options.toolCallId, {
metadata: {
approval: PermissionProvenance.classifyDenial({
ruleset: err.ruleset,
permission: req.permission,
patterns: req.patterns,
agent: input.agent.name,
origins: permissionOrigins,
}),
approval: PermissionProvenance.tagOutsideWorkspace(
PermissionProvenance.classifyDenial({
ruleset: err.ruleset,
permission: req.permission,
patterns: req.patterns,
agent: input.agent.name,
origins: permissionOrigins,
}),
req.permission,
PermissionProvenance.filepathOf(req.metadata),
),
},
}),
),
@@ -121,6 +121,80 @@ describe("PermissionProvenance.carryApproval", () => {
test("returns the replacement as-is when it is undefined", () => {
expect(PermissionProvenance.carryApproval({ approval }, undefined)).toBeUndefined()
})
test("merges outsideWorkspace onto a replacement's own approval instead of clobbering it", () => {
// A file tool crossing the workspace boundary asks twice: external_directory first, then its
// own read/write/edit ask. The second ask's approval must not lose the outsideWorkspace marker.
const outside = { source: "manual" as const, outsideWorkspace: true }
const next = { approval: { source: "agent" as const, agent: "build" } }
expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toEqual({
approval: { source: "agent", agent: "build", outsideWorkspace: true },
})
})
test("also carries the outsideWorkspacePath forward alongside the marker", () => {
const outside = { source: "manual" as const, outsideWorkspace: true, outsideWorkspacePath: "/tmp/secret.txt" }
const next = { approval: { source: "agent" as const, agent: "build" } }
expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toEqual({
approval: { source: "agent", agent: "build", outsideWorkspace: true, outsideWorkspacePath: "/tmp/secret.txt" },
})
})
test("does not add outsideWorkspace when the prior approval was not outside the workspace", () => {
const next = { approval: { source: "agent" as const, agent: "build" } }
expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next)
})
test("leaves a replacement's own outsideWorkspace marker untouched", () => {
const outside = { source: "manual" as const, outsideWorkspace: true }
const next = { approval: { source: "agent" as const, agent: "build", outsideWorkspace: true } }
expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toBe(next)
})
})
describe("PermissionProvenance.tagOutsideWorkspace", () => {
test("marks an external_directory approval as outsideWorkspace", () => {
const approval = { source: "manual" as const }
expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory")).toEqual({
source: "manual",
outsideWorkspace: true,
})
})
test("leaves other permissions' approvals untouched", () => {
const approval = { source: "manual" as const }
expect(PermissionProvenance.tagOutsideWorkspace(approval, "read")).toBe(approval)
})
test("carries the target path when one is given", () => {
const approval = { source: "manual" as const }
expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory", "/tmp/secret.txt")).toEqual({
source: "manual",
outsideWorkspace: true,
outsideWorkspacePath: "/tmp/secret.txt",
})
})
test("omits outsideWorkspacePath when no path is given", () => {
const approval = { source: "manual" as const }
expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory")).toEqual({
source: "manual",
outsideWorkspace: true,
})
})
})
describe("PermissionProvenance.filepathOf", () => {
test("reads the filepath an external_directory ask's metadata carries", () => {
expect(PermissionProvenance.filepathOf({ filepath: "/tmp/secret.txt", parentDir: "/tmp" })).toBe(
"/tmp/secret.txt",
)
})
test("returns undefined when there is no filepath, e.g. a bash directory scan", () => {
expect(PermissionProvenance.filepathOf({ command: "cat /tmp/secret.txt", access: "read" })).toBeUndefined()
expect(PermissionProvenance.filepathOf(undefined)).toBeUndefined()
})
})
describe("askPermission returns provenance", () => {