Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-gh-windows-console.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Prevent extension-managed GitHub CLI commands from opening transient Windows Terminal windows.
2 changes: 2 additions & 0 deletions packages/kilo-vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ import { spawn, exec } from "../util/process"

The `spawn` wrapper covers long-lived processes (e.g. `kilo serve`). The `exec` wrapper covers short commands (e.g. `git`, `tar`). If you need the raw callback form of `execFile` for some reason, pass `windowsHide: true` explicitly in the options object.

Agent Manager uses read-only `gh` commands for PR status and PR import. Call `execGhRead` from `src/agent-manager/gh.ts` for those commands; on Windows it supplies `TZ=UTC` when no timezone is configured, preventing older `gh` releases from launching `tzutil.exe` in a visible console.

## Style

Follow monorepo root AGENTS.md style guide:
Expand Down
24 changes: 15 additions & 9 deletions packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ExecFileOptionsWithStringEncoding } from "child_process"
import type { Worktree } from "./WorktreeStateManager"
import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types"
import { execWithShellEnv } from "./shell-env"
import { execGhRead } from "./gh"
import { classifyPRError } from "./git-import"
import type { Semaphore } from "./semaphore"

Expand Down Expand Up @@ -59,6 +60,14 @@ export class PRStatusPoller {
return this.semaphore ? this.semaphore.run(invoke) : invoke()
}

private gh(
args: string[],
options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">,
): Promise<{ stdout: string; stderr: string }> {
const invoke = () => execGhRead(args, options)
return this.semaphore ? this.semaphore.run(invoke) : invoke()
}

setEnabled(enabled: boolean): void {
if (enabled) {
if (this.active) return
Expand Down Expand Up @@ -166,7 +175,7 @@ export class PRStatusPoller {
return this.ghAvailable
}
try {
await this.shell("gh", ["--version"], { timeout: 5_000 })
await this.gh(["--version"], { timeout: 5_000 })
this.ghAvailable = true
} catch {
this.ghAvailable = false
Expand Down Expand Up @@ -311,7 +320,7 @@ export class PRStatusPoller {
if (branch) args.push(branch)
args.push("--json", PRStatusPoller.PR_JSON_FIELDS)

const { stdout } = await this.shell("gh", args, { cwd, timeout: 15_000 })
const { stdout } = await this.gh(args, { cwd, timeout: 15_000 })
return parsePRResult(stdout)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
Expand All @@ -327,8 +336,7 @@ export class PRStatusPoller {
const head = sha.trim()
if (!head) return null

const { stdout } = await this.shell(
"gh",
const { stdout } = await this.gh(
[
"pr",
"list",
Expand Down Expand Up @@ -369,8 +377,7 @@ export class PRStatusPoller {
items: PRCheck[]
}> {
try {
const { stdout } = await this.shell(
"gh",
const { stdout } = await this.gh(
["pr", "checks", String(prNumber), "--json", "name,state,link,startedAt,completedAt"],
{ cwd, timeout: 15_000 },
)
Expand Down Expand Up @@ -407,7 +414,7 @@ export class PRStatusPoller {
if (this.cachedRepo && this.cachedRepo.cwd === cwd) {
return this.cachedRepo
}
const { stdout } = await this.shell("gh", ["repo", "view", "--json", "owner,name"], {
const { stdout } = await this.gh(["repo", "view", "--json", "owner,name"], {
cwd,
timeout: 10_000,
})
Expand Down Expand Up @@ -447,8 +454,7 @@ export class PRStatusPoller {
}
}`

const { stdout } = await this.shell(
"gh",
const { stdout } = await this.gh(
[
"api",
"graphql",
Expand Down
9 changes: 7 additions & 2 deletions packages/kilo-vscode/src/agent-manager/WorktreeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import simpleGit, { type SimpleGit } from "simple-git"
import { generateBranchName, sanitizeBranchName } from "./branch-name"
import { type GitOps, isKiloOwnedSshCommand, nonInteractiveEnv } from "./GitOps"
import { execWithShellEnv } from "./shell-env"
import { execGhRead } from "./gh"
import { markNoIndex } from "../util/spotlight"
import {
parsePRUrl,
Expand Down Expand Up @@ -1043,8 +1044,7 @@ export class WorktreeManager {

private async fetchPRInfo(parsed: { owner: string; repo: string; number: number }): Promise<PRInfo> {
try {
const json = await this.exec(
"gh",
const json = await this.gh(
[
"pr",
"view",
Expand Down Expand Up @@ -1100,6 +1100,11 @@ export class WorktreeManager {
return stdout
}

private async gh(args: string[], timeout = 120000): Promise<string> {
const { stdout } = await execGhRead(args, { cwd: this.root, timeout })
return stdout
}

private async gitExec(args: string[]): Promise<void> {
await this.exec("git", args)
}
Expand Down
18 changes: 18 additions & 0 deletions packages/kilo-vscode/src/agent-manager/gh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ExecFileOptionsWithStringEncoding } from "child_process"
import { execWithShellEnv } from "./shell-env"

function env(options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">): NodeJS.ProcessEnv {
const result = options?.env ? { ...options.env } : { ...process.env }
const tz = Object.keys(result).find((key) => key.toLowerCase() === "tz")
if (!tz) result.TZ = "UTC"
return result
}

/** Run read-only gh queries without tzutil console windows flashing on Windows. */
export function execGhRead(
args: string[],
options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">,
): Promise<{ stdout: string; stderr: string }> {
if (process.platform !== "win32") return execWithShellEnv("gh", args, options)
return execWithShellEnv("gh", args, { ...options, env: env(options) })
}
10 changes: 10 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,16 @@ function agentManagerSourceFiles(): string[] {
}

describe("Agent Manager — VS Code import boundary", () => {
it("routes GitHub CLI execution through execGhRead", () => {
const gh = path.join(AGENT_MANAGER_DIR, "gh.ts")
const violations = agentManagerSourceFiles()
.map((file) => path.join(AGENT_MANAGER_DIR, file))
.filter((file) => file !== gh)
.filter((file) => /(["'])gh(?:\.exe)?\1/.test(fs.readFileSync(file, "utf8")))
.map((file) => path.basename(file))
expect(violations).toEqual([])
})

it("only allowlisted files may import vscode", () => {
const violations: string[] = []
for (const file of agentManagerSourceFiles()) {
Expand Down
95 changes: 95 additions & 0 deletions packages/kilo-vscode/tests/unit/gh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it } from "bun:test"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import { execGhRead } from "../../src/agent-manager/gh"

const host = process.platform
const platform = Object.getOwnPropertyDescriptor(process, "platform")

function setPlatform(value: string): void {
Object.defineProperty(process, "platform", { value, configurable: true })
}

function link(src: string, dest: string): void {
try {
fs.linkSync(src, dest)
} catch {
fs.copyFileSync(src, dest)
}
if (host !== "win32") fs.chmodSync(dest, 0o755)
}

function fakeBin(): { dir: string; cleanup: () => void } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-process-"))
const name = host === "win32" ? "gh.exe" : "gh"
try {
link(process.execPath, path.join(dir, name))
return { dir, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }
} catch (error) {
fs.rmSync(dir, { recursive: true, force: true })
throw error
}
}

function env(dir: string): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (typeof value === "string") result[key] = value
}
const key = Object.keys(result).find((key) => key.toLowerCase() === "path") ?? "PATH"
result[key] = dir
result.PATHEXT = ".COM;.EXE;.BAT;.CMD"
return result
}

function unset(env: Record<string, string>, name: string): void {
for (const key of Object.keys(env)) {
if (key.toLowerCase() === name.toLowerCase()) delete env[key]
}
}

afterEach(() => {
if (platform) Object.defineProperty(process, "platform", platform)
})

describe("execGhRead", () => {
it("uses UTC when TZ is unset on Windows", async () => {
setPlatform("win32")
const bin = fakeBin()
try {
const child = env(bin.dir)
unset(child, "TZ")
const { stdout } = await execGhRead(["-e", "console.log(process.env.TZ)"], { env: child })
expect(stdout.trim()).toBe("UTC")
} finally {
bin.cleanup()
}
})

it("preserves an existing TZ on Windows", async () => {
setPlatform("win32")
const bin = fakeBin()
try {
const child = env(bin.dir)
child.TZ = "Europe/London"
const { stdout } = await execGhRead(["-e", "console.log(process.env.TZ)"], { env: child })
expect(stdout.trim()).toBe("Europe/London")
} finally {
bin.cleanup()
}
})

it("does not add TZ on non-Windows platforms", async () => {
setPlatform("linux")
const bin = fakeBin()
try {
const child = env(bin.dir)
unset(child, "TZ")
const { stdout } = await execGhRead(["-e", "console.log(process.env.TZ)"], { env: child })
expect(stdout.trim()).toBe("undefined")
} finally {
bin.cleanup()
}
})
})
Loading