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
7 changes: 7 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,12 @@ jobs:
export XDG_CACHE_HOME="$root/cache"
export XDG_CONFIG_HOME="$root/config"
export XDG_STATE_HOME="$root/state"
export KILO_PTY_SMOKE=1 # kilocode_change
export KILO_DISABLE_MODELS_FETCH=1
export KILO_DISABLE_PROJECT_CONFIG=1
export KILO_CONFIG_CONTENT='{"enabled_providers":["anthropic"]}'
export ANTHROPIC_API_KEY=dummy
"$binary" --pure __pty-smoke # kilocode_change
"$binary" --pure models anthropic | grep -q '^anthropic/'
)
}
Expand Down Expand Up @@ -247,10 +249,12 @@ jobs:
export XDG_CACHE_HOME="$root/cache"
export XDG_CONFIG_HOME="$root/config"
export XDG_STATE_HOME="$root/state"
export KILO_PTY_SMOKE=1 # kilocode_change
export KILO_DISABLE_MODELS_FETCH=1
export KILO_DISABLE_PROJECT_CONFIG=1
export KILO_CONFIG_CONTENT='\''{"enabled_providers":["anthropic"]}'\''
export ANTHROPIC_API_KEY=dummy
"$binary" --pure __pty-smoke # kilocode_change
"$binary" --pure models anthropic | grep -q "^anthropic/"
'

Expand Down Expand Up @@ -307,10 +311,13 @@ jobs:
$env:XDG_CACHE_HOME = Join-Path $root "cache"
$env:XDG_CONFIG_HOME = Join-Path $root "config"
$env:XDG_STATE_HOME = Join-Path $root "state"
$env:KILO_PTY_SMOKE = "1" # kilocode_change
$env:KILO_DISABLE_MODELS_FETCH = "1"
$env:KILO_DISABLE_PROJECT_CONFIG = "1"
$env:KILO_CONFIG_CONTENT = '{"enabled_providers":["anthropic"]}'
$env:ANTHROPIC_API_KEY = "dummy"
& $binary --pure __pty-smoke # kilocode_change
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # kilocode_change
$output = & $binary --pure models anthropic
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not ($output -match "(?m)^anthropic/")) {
Expand Down
20 changes: 16 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@ jobs:
run: |
if [ "$GENERAL" != "true" ]; then
echo 'general=false' >> "$GITHUB_OUTPUT"
echo 'settings=[{"os":"linux","index":1,"total":1,"host":"blacksmith-4vcpu-ubuntu-2404","run":false,"packages":false}]' >> "$GITHUB_OUTPUT"
echo 'settings=[{"os":"linux","index":1,"total":1,"host":"blacksmith-4vcpu-ubuntu-2404","run":false,"packages":false,"pty":false}]' >> "$GITHUB_OUTPUT"
exit 0
fi
echo 'general=true' >> "$GITHUB_OUTPUT"
# kilocode_change - Windows runs 6 shards at KILO_TEST_CONCURRENCY=2. Measured: a
# 5-shard matrix was worse on both wall-clock (slowest job 9.3m vs 6.7m) and machine
# minutes (43 vs 37) — with 2 workers per shard, packing more work per shard loses.
echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":5,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":6,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT"
echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true,"pty":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false,"pty":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true,"pty":true},{"os":"windows","index":1,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true,"pty":true},{"os":"windows","index":2,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":3,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":4,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":5,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":6,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false}]' >> "$GITHUB_OUTPUT"
# kilocode_change end
unit:
# kilocode_change start
Expand Down Expand Up @@ -118,7 +118,6 @@ jobs:
}
Write-Host "Defender path exclusions applied: $applied"
# kilocode_change end

- name: Checkout repository
if: matrix.settings.run # kilocode_change
uses: actions/checkout@v6 # kilocode_change
Expand Down Expand Up @@ -187,8 +186,21 @@ jobs:
run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains'
env:
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - non-CLI tests use the watcher-free unit-test profile
# kilocode_change end

# Keep this outside the CLI shard and OS profiles so one real PTY contract
# always runs on Linux, macOS, and Windows.
- name: Run cross-platform PTY service tests
if: matrix.settings.run && matrix.settings.pty
working-directory: packages/core
run: bun test test/kilocode/pty-platform.test.ts --timeout 60000

- name: Run cross-platform PTY route tests
if: matrix.settings.run && matrix.settings.pty
working-directory: packages/opencode
run: |
bun test test/server/httpapi-pty.test.ts --test-name-pattern "serves Agent Manager regular terminal" --timeout 60000
bun test test/server/httpapi-v2-pty.test.ts --test-name-pattern "serves Agent Manager script terminal" --timeout 60000
# kilocode_change end
# kilocode_change start - ensure the Darwin profile cannot suppress its own validation
- name: Validate Darwin CLI test profile
if: matrix.settings.run && matrix.settings.os == 'macos'
Expand Down
72 changes: 72 additions & 0 deletions packages/core/src/kilocode/pty/smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Shell } from "../../shell"
import { KiloPtyTermination } from "./termination"
import { spawn } from "#pty"

const TIMEOUT = 15_000

export async function smoke() {
const proc = spawn(Shell.preferred(), [], {
name: "xterm-256color",
cwd: process.cwd(),
env: { ...process.env, TERM: "xterm-256color", KILO_TERMINAL: "1" } as Record<string, string>,
cols: 80,
rows: 24,
})
const state = { output: "", exited: false }
const output = Promise.withResolvers<void>()
const exited = Promise.withResolvers<number>()
const data = proc.onData((chunk) => {
state.output += chunk
const lines = state.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").split(/\r?\n/)
if (lines.some((line) => line.trim() === "KILO_PTY_READY")) output.resolve()
})
const exit = proc.onExit((event) => {
state.exited = true
exited.resolve(event.exitCode)
})
const timeout = AbortSignal.timeout(TIMEOUT)

try {
proc.resize(100, 40)
proc.write("echo KILO_PTY_READY\r")
await Promise.race([
output.promise,
new Promise<never>((_, reject) =>
timeout.addEventListener(
"abort",
() => reject(new Error(`PTY produced no output within ${TIMEOUT}ms: ${JSON.stringify(state.output)}`)),
{ once: true },
),
),
])
proc.write("exit 7\r")
const code = await Promise.race([
exited.promise,
new Promise<never>((_, reject) =>
timeout.addEventListener("abort", () => reject(new Error(`PTY did not exit within ${TIMEOUT}ms`)), {
once: true,
}),
),
])
if (code !== 7) throw new Error(`PTY exited ${code}, expected 7`)
} finally {
data.dispose()
exit.dispose()
if (!state.exited) proc.kill()
}

const active = spawn(Shell.preferred(), [], {
name: "xterm-256color",
cwd: process.cwd(),
env: process.env as Record<string, string>,
})
let stopped = false
try {
await KiloPtyTermination.terminate(active)
stopped = true
} finally {
if (!stopped) active.kill()
}
}

export * as PtySmoke from "./smoke"
28 changes: 27 additions & 1 deletion packages/core/src/kilocode/pty/termination.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn } from "child_process"
import { readdir, readFile } from "node:fs/promises"
import { setTimeout as sleep } from "node:timers/promises"
import type { Proc } from "../../pty/pty"
import { Log } from "../../util/log"
Expand Down Expand Up @@ -87,6 +88,15 @@ function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", inp
}

async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) {
if (process.platform === "linux") {
try {
const rows = await procTree()
if (rows.length > 0) return rows
} catch (err) {
log.debug("failed to read Linux process tree", { err })
}
}

return await new Promise<Array<{ pid: number; parent: number }>>((resolve, reject) => {
try {
const child = spawn(file, args, {
Expand Down Expand Up @@ -116,6 +126,22 @@ async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]
})
}

async function procTree() {
const entries = await readdir("/proc", { withFileTypes: true })
const rows = await Promise.all(
entries
.filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
.map(async (entry) => {
const stat = await readFile(`/proc/${entry.name}/stat`, "utf8").catch(() => undefined)
if (!stat) return
const match = stat.match(/^\d+ \(.*\) [A-Z] (\d+)/)
if (!match) return
return { pid: Number(entry.name), parent: Number(match[1]) }
}),
)
return rows.filter((row): row is { pid: number; parent: number } => row !== undefined)
}

async function taskkill(file: string, args: string[], opts: { stdio: "ignore"; windowsHide: true; timeout: number }) {
return await new Promise<boolean>((resolve) => {
try {
Expand Down Expand Up @@ -153,7 +179,7 @@ export async function terminate(proc: Process, input: Runtime = runtime): Promis
})
if ((!killed || input.alive(proc.pid)) && !state.exited) direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
await verify(proc, state.exited, input)
if (!state.exited && input.alive(proc.pid)) throw new Error(`PTY process tree is still alive: ${proc.pid}`)
return
}

Expand Down
96 changes: 96 additions & 0 deletions packages/core/test/kilocode/pty-platform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Config } from "@opencode-ai/core/config"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Deferred, Effect, Layer, Queue } from "effect"
import os from "node:os"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"

const directory = os.tmpdir()
const layer = AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
[Config.node, Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })],
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
])
const it = testEffect(layer)

async function alive(pid: number) {
if (process.platform !== "win32") {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}

const proc = Bun.spawn(["tasklist", "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
stdout: "pipe",
stderr: "ignore",
windowsHide: true,
})
const output = await new Response(proc.stdout).text()
await proc.exited
return output.includes(`"${pid}"`)
}

const attach = Effect.fn("PtyPlatformTest.attach")(function* (id: Pty.Info["id"]) {
const pty = yield* Pty.Service
const output = yield* Queue.unbounded<string>()
const ended = yield* Deferred.make<{ exitCode?: number }>()
const attachment = yield* pty.attach(id, {
onData: (data) => Queue.offerUnsafe(output, data),
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
})
attachment.activate()
return { attachment, output, ended }
})

describe("cross-platform PTY", () => {
it.live("starts the default shell and exits from input", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* Effect.acquireRelease(pty.create({ cwd: directory }), (item) =>
pty.remove(item.id).pipe(Effect.ignore),
)
const terminal = yield* attach(info.id)
terminal.attachment.write("exit\r")
expect(yield* Deferred.await(terminal.ended).pipe(Effect.timeout("15 seconds"))).toEqual({ exitCode: 0 })
}),
)

it.live("terminates a spawned process tree", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const source = [
'const child = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], {',
' stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true,',
"})",
"process.stdout.write(`CHILD:${child.pid}\\n`)",
"setInterval(() => {}, 1000)",
].join("\n")
const info = yield* Effect.acquireRelease(
pty.create({ command: process.execPath, args: ["-e", source], cwd: directory }),
(item) => pty.remove(item.id).pipe(Effect.ignore),
)
const terminal = yield* attach(info.id)
const output = yield* Effect.gen(function* () {
let text = ""
while (!text.includes("CHILD:")) text += yield* Queue.take(terminal.output)
return text
}).pipe(Effect.timeout("15 seconds"))
const pid = Number(output.match(/CHILD:(\d+)/)?.[1])
expect(pid).toBeGreaterThan(0)

yield* pty.remove(info.id)
expect(yield* Effect.promise(() => alive(pid))).toBe(false)
}),
)
})
11 changes: 11 additions & 0 deletions packages/opencode/src/kilocode/cli/cmd/pty-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { cmd } from "@/cli/cmd/cmd"

export const PtySmokeCommand = cmd({
command: "__pty-smoke",
describe: false,
async handler() {
if (process.env.KILO_PTY_SMOKE !== "1") throw new Error("PTY smoke command is release-only")
const { PtySmoke } = await import("@opencode-ai/core/kilocode/pty/smoke")
await PtySmoke.smoke()
},
})
2 changes: 2 additions & 0 deletions packages/opencode/src/kilocode/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { DevSetupCommand, DevAliasCommand } from "@/kilocode/cli/dev-setup"
import { RemoteCommand } from "@/cli/cmd/remote"
import { ConfigCommand as ConfigCLICommand } from "@/cli/cmd/config"
import { WorktreeCommand } from "@/kilocode/cli/cmd/worktree"
import { PtySmokeCommand } from "@/kilocode/cli/cmd/pty-smoke"

const log = Log.create({ service: "kilocode.cli" })

Expand Down Expand Up @@ -58,6 +59,7 @@ export namespace KiloCli {
.command(DaemonCommand)
.command(ConfigCLICommand)
.command(WorktreeCommand)
if (process.env.KILO_PTY_SMOKE === "1") cli.command(PtySmokeCommand)
if (InstallationBuildKind !== "release") cli.command(DevSetupCommand).command(DevAliasCommand)
// Safe self-reference: `cli` is a typed parameter and yargs `.command()` returns the same
// instance, so the help command can resolve the fully-built root at handler time. This also
Expand Down
Loading
Loading