From a35585ad9d262efd029f89a77852f3e514349ac9 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 13:46:27 +0200 Subject: [PATCH 1/7] test(cli): validate PTY across release targets --- .github/workflows/publish.yml | 7 + .github/workflows/test.yml | 20 ++- packages/core/src/kilocode/pty/smoke.ts | 71 ++++++++++ .../core/test/kilocode/pty-platform.test.ts | 76 +++++++++++ .../src/kilocode/cli/cmd/pty-smoke.ts | 11 ++ packages/opencode/src/kilocode/cli/setup.ts | 2 + .../opencode/test/server/httpapi-pty.test.ts | 103 ++++++++------ .../test/server/httpapi-v2-pty.test.ts | 129 ++++++++++++------ 8 files changed, 335 insertions(+), 84 deletions(-) create mode 100644 packages/core/src/kilocode/pty/smoke.ts create mode 100644 packages/core/test/kilocode/pty-platform.test.ts create mode 100644 packages/opencode/src/kilocode/cli/cmd/pty-smoke.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9d8ad01d2fc..e6dcfbffbf1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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/' ) } @@ -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/" ' @@ -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/")) { diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf54aa3081b..4329acf219f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -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 @@ -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' diff --git a/packages/core/src/kilocode/pty/smoke.ts b/packages/core/src/kilocode/pty/smoke.ts new file mode 100644 index 00000000000..2d2c4472137 --- /dev/null +++ b/packages/core/src/kilocode/pty/smoke.ts @@ -0,0 +1,71 @@ +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, + cols: 80, + rows: 24, + }) + const state = { output: "", exited: false } + const output = Promise.withResolvers() + const exited = Promise.withResolvers() + const data = proc.onData((chunk) => { + state.output += chunk + if (state.output.includes("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((_, 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((_, 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, + }) + let stopped = false + try { + await KiloPtyTermination.terminate(active) + stopped = true + } finally { + if (!stopped) active.kill() + } +} + +export * as PtySmoke from "./smoke" diff --git a/packages/core/test/kilocode/pty-platform.test.ts b/packages/core/test/kilocode/pty-platform.test.ts new file mode 100644 index 00000000000..4d1e4196ce8 --- /dev/null +++ b/packages/core/test/kilocode/pty-platform.test.ts @@ -0,0 +1,76 @@ +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) + +const attach = Effect.fn("PtyPlatformTest.attach")(function* (id: Pty.Info["id"]) { + const pty = yield* Pty.Service + const output = yield* Queue.unbounded() + 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(() => process.kill(pid, 0)).toThrow() + }), + ) +}) diff --git a/packages/opencode/src/kilocode/cli/cmd/pty-smoke.ts b/packages/opencode/src/kilocode/cli/cmd/pty-smoke.ts new file mode 100644 index 00000000000..1f8995bea84 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/pty-smoke.ts @@ -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() + }, +}) diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index d7fd3e3c071..c434ab60ee1 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -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" }) @@ -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 diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 3f5b5fc2515..5807c1e8340 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -269,48 +269,69 @@ describe("pty HttpApi bridge", () => { message: `PTY session not found: ${missingID}`, }) }) - ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( - "serves PTY websocket output and input through Effect routes", - () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) - const created = yield* HttpClientRequest.post(PtyPaths.create).pipe( - directoryHeader(dir), - HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }), - Effect.flatMap(HttpClient.execute), + // kilocode_change start - portable coverage for the exact legacy routes used by regular Agent Manager terminals + effectIt.live("serves Agent Manager regular terminal create, resize, input, output, and remove routes", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) + const child = [ + 'let input = ""', + "process.stdout.write(`READY:${process.stdout.isTTY}:${process.stdout.columns}x${process.stdout.rows}\\n`)", + 'process.stdin.setEncoding("utf8")', + 'process.stdin.on("data", (chunk) => {', + " input += chunk", + ' if (input.includes("PING")) process.stdout.write(`PONG:${process.stdout.columns}x${process.stdout.rows}\\n`)', + "})", + ].join("\n") + const created = yield* HttpClientRequest.post(PtyPaths.create).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ + command: process.execPath, + args: ["-e", child], + title: "websocket", + size: { cols: 80, rows: 24 }, + }), + Effect.flatMap(HttpClient.execute), + ) + expect(created.status).toBe(200) + const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json) + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=0&directory=${encodeURIComponent(dir)}`, + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket + .runRaw((message) => + Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), ) - expect(created.status).toBe(200) - const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json) + .pipe(Effect.catch(() => Effect.void)) + .pipe(Effect.forkScoped) + const write = yield* socket.writer + + const takeUntil = (expected: string, seen = ""): Effect.Effect => + Effect.gen(function* () { + const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) + if (next.includes(expected)) return next + return yield* takeUntil(expected, next) + }) + + expect(yield* takeUntil("READY:")).toContain("READY:true:80x24") + const updated = yield* HttpClientRequest.put(PtyPaths.update.replace(":ptyID", info.id)).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ size: { cols: 100, rows: 40 } }), + Effect.flatMap(HttpClient.execute), + ) + expect(updated.status).toBe(200) - const socket = yield* Socket.makeWebSocket( - `${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`, - { closeCodeIsError: () => false }, - ) - const messages = yield* Queue.unbounded() - yield* socket - .runRaw((message) => - Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), - ) - .pipe(Effect.catch(() => Effect.void)) - .pipe(Effect.forkScoped) - const write = yield* socket.writer - - const takeUntil = (expected: string, seen = ""): Effect.Effect => - Effect.gen(function* () { - const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) - if (next.includes(expected)) return next - return yield* takeUntil(expected, next) - }) - - yield* write("ping-route\n") - expect(yield* takeUntil("ping-route")).toContain("ping-route") - yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) - - const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe( - directoryHeader(dir), - HttpClient.execute, - ) - expect(removed.status).toBe(200) - }), + yield* write("PING\r") + expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40") + + const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe( + directoryHeader(dir), + HttpClient.execute, + ) + expect(removed.status).toBe(200) + }), ) + // kilocode_change end }) diff --git a/packages/opencode/test/server/httpapi-v2-pty.test.ts b/packages/opencode/test/server/httpapi-v2-pty.test.ts index db74a5b649d..3384139139d 100644 --- a/packages/opencode/test/server/httpapi-v2-pty.test.ts +++ b/packages/opencode/test/server/httpapi-v2-pty.test.ts @@ -134,51 +134,102 @@ describe("v2 pty HttpApi", () => { await request(`/api/pty/${info.id}`, tmp.path, { method: "DELETE" }) } }) - ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( - "serves PTY websocket output and input through the canonical route", - () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) - const created = yield* HttpClientRequest.post("/api/pty").pipe( - directoryHeader(dir), - HttpClientRequest.bodyJson({ command: "/bin/cat", title: "v2-websocket" }), - Effect.flatMap(HttpClient.execute), - ) - expect(created.status).toBe(200) - const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json) - const info = body.data + // kilocode_change start - portable live PTY coverage on Linux, macOS, and Windows CI + effectIt.live("serves Agent Manager script terminal create, resize, input, output, exit, and remove routes", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) + const child = [ + 'const state = { input: "", pong: false }', + "process.stdout.write(`READY:${process.stdout.isTTY}:${process.stdout.columns}x${process.stdout.rows}\\n`)", + 'process.stdin.setEncoding("utf8")', + 'process.stdin.on("data", (chunk) => {', + " state.input += chunk", + ' if (!state.pong && state.input.includes("PING")) {', + " state.pong = true", + " process.stdout.write(`PONG:${process.stdout.columns}x${process.stdout.rows}\\n`)", + " }", + ' if (state.input.includes("EXIT")) process.exit(7)', + "})", + ].join("\n") + const created = yield* HttpClientRequest.post("/api/pty").pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ + command: process.execPath, + args: ["-e", child], + title: "v2-websocket", + size: { cols: 80, rows: 24 }, + }), + Effect.flatMap(HttpClient.execute), + ) + expect(created.status).toBe(200) + const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json) + const info = body.data - const socket = yield* Socket.makeWebSocket( - `${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=-1&location[directory]=${encodeURIComponent(dir)}`, - { closeCodeIsError: () => false }, + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=0&location[directory]=${encodeURIComponent(dir)}`, + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket + .runRaw((message) => + Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), ) - const messages = yield* Queue.unbounded() - yield* socket - .runRaw((message) => - Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), - ) - .pipe(Effect.catch(() => Effect.void)) - .pipe(Effect.forkScoped) - const write = yield* socket.writer + .pipe(Effect.catch(() => Effect.void)) + .pipe(Effect.forkScoped) + const write = yield* socket.writer - const takeUntil = (expected: string, seen = ""): Effect.Effect => - Effect.gen(function* () { - const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) - if (next.includes(expected)) return next - return yield* takeUntil(expected, next) - }) + const takeUntil = (expected: string, seen = ""): Effect.Effect => + Effect.gen(function* () { + const next = + seen + + (yield* Queue.take(messages).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new Error( + `PTY output did not contain ${JSON.stringify(expected)}, received ${JSON.stringify(seen)}`, + ), + ), + }), + )) + if (next.includes(expected)) return next + return yield* takeUntil(expected, next) + }) - yield* write("ping-v2\n") - expect(yield* takeUntil("ping-v2")).toContain("ping-v2") - yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) + expect(yield* takeUntil("READY:")).toContain("READY:true:80x24") + const resized = yield* HttpClientRequest.put(`/api/pty/${info.id}`).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ size: { cols: 100, rows: 40 } }), + Effect.flatMap(HttpClient.execute), + ) + expect(resized.status).toBe(200) - const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe( - directoryHeader(dir), - HttpClient.execute, - ) - expect(removed.status).toBe(204) - }), + yield* write("PING\r") + expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40") + yield* write("EXIT\r") + const exit = yield* Effect.gen(function* () { + while (true) { + const response = yield* HttpClientRequest.get(`/api/pty/${info.id}`).pipe( + directoryHeader(dir), + HttpClient.execute, + ) + expect(response.status).toBe(200) + const data = (yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* response.json)).data + if (data.status === "exited") return data + yield* Effect.sleep("20 millis") + } + }).pipe(Effect.timeout("5 seconds")) + expect(exit).toMatchObject({ status: "exited", exitCode: 7 }) + + const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe( + directoryHeader(dir), + HttpClient.execute, + ) + expect(removed.status).toBe(204) + }), ) + // kilocode_change end ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( "applies plugin shell environment before forced PTY values", () => From 575a4ad81945a99f42a4c7ccd7b30375d3314601 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 13:50:43 +0200 Subject: [PATCH 2/7] chore(ci): simplify PTY smoke annotations --- .github/workflows/publish.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e6dcfbffbf1..e8c3e0a84c4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -211,12 +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_PTY_SMOKE=1 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 __pty-smoke "$binary" --pure models anthropic | grep -q '^anthropic/' ) } @@ -249,12 +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_PTY_SMOKE=1 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 __pty-smoke "$binary" --pure models anthropic | grep -q "^anthropic/" ' @@ -311,13 +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_PTY_SMOKE = "1" $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 + & $binary --pure __pty-smoke + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $output = & $binary --pure models anthropic if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not ($output -match "(?m)^anthropic/")) { From 56cf3934c0bf7d8f192d3ae58630a1c32434a5d5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 13:53:38 +0200 Subject: [PATCH 3/7] fix(ci): annotate all PTY smoke commands --- .github/workflows/publish.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e8c3e0a84c4..e6dcfbffbf1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -211,12 +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 + 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 + "$binary" --pure __pty-smoke # kilocode_change "$binary" --pure models anthropic | grep -q '^anthropic/' ) } @@ -249,12 +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 + 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 + "$binary" --pure __pty-smoke # kilocode_change "$binary" --pure models anthropic | grep -q "^anthropic/" ' @@ -311,13 +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" + $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 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $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/")) { From 35de2d1ec23698248e4447e558207b325187d50d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 14:14:10 +0200 Subject: [PATCH 4/7] fix(test): stabilize cross-platform PTY checks --- packages/core/src/kilocode/pty/termination.ts | 2 +- packages/core/test/kilocode/pty-platform.test.ts | 7 ++++++- packages/opencode/test/server/httpapi-pty.test.ts | 5 +++++ packages/opencode/test/server/httpapi-v2-pty.test.ts | 1 + 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/core/src/kilocode/pty/termination.ts b/packages/core/src/kilocode/pty/termination.ts index 33b29936d20..51aa3d9f5d8 100644 --- a/packages/core/src/kilocode/pty/termination.ts +++ b/packages/core/src/kilocode/pty/termination.ts @@ -153,7 +153,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 } diff --git a/packages/core/test/kilocode/pty-platform.test.ts b/packages/core/test/kilocode/pty-platform.test.ts index 4d1e4196ce8..ed07f32de6c 100644 --- a/packages/core/test/kilocode/pty-platform.test.ts +++ b/packages/core/test/kilocode/pty-platform.test.ts @@ -70,7 +70,12 @@ describe("cross-platform PTY", () => { expect(pid).toBeGreaterThan(0) yield* pty.remove(info.id) - expect(() => process.kill(pid, 0)).toThrow() + if (process.platform === "win32") { + const result = yield* Effect.promise(() => Bun.$`taskkill /pid ${pid} /f /t`.quiet().nothrow()) + expect(result.exitCode).toBe(128) + } else { + expect(() => process.kill(pid, 0)).toThrow() + } }), ) }) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 5807c1e8340..a18624b589b 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -14,6 +14,10 @@ import { testEffect } from "../lib/effect" const testPty = process.platform === "win32" ? test.skip : test +// kilocode_change start - route PTY tests must not compete with per-project indexing workers. +process.env.KILO_DISABLE_CODEBASE_INDEXING = "vscode-no-workspace" +// kilocode_change end + const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { yield* Effect.promise(() => resetDatabase()) @@ -325,6 +329,7 @@ describe("pty HttpApi bridge", () => { yield* write("PING\r") expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40") + yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe( directoryHeader(dir), diff --git a/packages/opencode/test/server/httpapi-v2-pty.test.ts b/packages/opencode/test/server/httpapi-v2-pty.test.ts index 3384139139d..0087593eb44 100644 --- a/packages/opencode/test/server/httpapi-v2-pty.test.ts +++ b/packages/opencode/test/server/httpapi-v2-pty.test.ts @@ -208,6 +208,7 @@ describe("v2 pty HttpApi", () => { yield* write("PING\r") expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40") yield* write("EXIT\r") + yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) const exit = yield* Effect.gen(function* () { while (true) { const response = yield* HttpClientRequest.get(`/api/pty/${info.id}`).pipe( From 8d0750f563200d0a6d1f367218222a9c4bf630c2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 14:24:08 +0200 Subject: [PATCH 5/7] fix(test): handle platform PTY cleanup --- packages/opencode/test/server/httpapi-pty.test.ts | 4 ++-- packages/opencode/test/server/httpapi-v2-pty.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index a18624b589b..ad16b28ba45 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -283,7 +283,7 @@ describe("pty HttpApi bridge", () => { 'process.stdin.setEncoding("utf8")', 'process.stdin.on("data", (chunk) => {', " input += chunk", - ' if (input.includes("PING")) process.stdout.write(`PONG:${process.stdout.columns}x${process.stdout.rows}\\n`)', + ' if (input.includes("PING")) process.stdout.write("PONG\\n")', "})", ].join("\n") const created = yield* HttpClientRequest.post(PtyPaths.create).pipe( @@ -328,7 +328,7 @@ describe("pty HttpApi bridge", () => { expect(updated.status).toBe(200) yield* write("PING\r") - expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40") + expect(yield* takeUntil("PONG")).toContain("PONG") yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe( diff --git a/packages/opencode/test/server/httpapi-v2-pty.test.ts b/packages/opencode/test/server/httpapi-v2-pty.test.ts index 0087593eb44..823281bdfc9 100644 --- a/packages/opencode/test/server/httpapi-v2-pty.test.ts +++ b/packages/opencode/test/server/httpapi-v2-pty.test.ts @@ -146,7 +146,7 @@ describe("v2 pty HttpApi", () => { " state.input += chunk", ' if (!state.pong && state.input.includes("PING")) {', " state.pong = true", - " process.stdout.write(`PONG:${process.stdout.columns}x${process.stdout.rows}\\n`)", + ' process.stdout.write("PONG\\n")', " }", ' if (state.input.includes("EXIT")) process.exit(7)', "})", @@ -206,7 +206,7 @@ describe("v2 pty HttpApi", () => { expect(resized.status).toBe(200) yield* write("PING\r") - expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40") + expect(yield* takeUntil("PONG")).toContain("PONG") yield* write("EXIT\r") yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) const exit = yield* Effect.gen(function* () { From 05cbbdd84ac120cee4790b6b5259ea7bbd98ac55 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 15:44:01 +0200 Subject: [PATCH 6/7] fix(pty): harden cross-platform smoke checks --- packages/core/src/kilocode/pty/smoke.ts | 3 ++- packages/core/src/kilocode/pty/termination.ts | 25 +++++++++++++++++ .../core/test/kilocode/pty-platform.test.ts | 27 ++++++++++++++----- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/core/src/kilocode/pty/smoke.ts b/packages/core/src/kilocode/pty/smoke.ts index 2d2c4472137..fa5a4e01bef 100644 --- a/packages/core/src/kilocode/pty/smoke.ts +++ b/packages/core/src/kilocode/pty/smoke.ts @@ -17,7 +17,8 @@ export async function smoke() { const exited = Promise.withResolvers() const data = proc.onData((chunk) => { state.output += chunk - if (state.output.includes("KILO_PTY_READY")) output.resolve() + 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 diff --git a/packages/core/src/kilocode/pty/termination.ts b/packages/core/src/kilocode/pty/termination.ts index 51aa3d9f5d8..c9862adbd8b 100644 --- a/packages/core/src/kilocode/pty/termination.ts +++ b/packages/core/src/kilocode/pty/termination.ts @@ -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" @@ -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>((resolve, reject) => { try { const child = spawn(file, args, { @@ -116,6 +126,21 @@ 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") + 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((resolve) => { try { diff --git a/packages/core/test/kilocode/pty-platform.test.ts b/packages/core/test/kilocode/pty-platform.test.ts index ed07f32de6c..273eacdedcd 100644 --- a/packages/core/test/kilocode/pty-platform.test.ts +++ b/packages/core/test/kilocode/pty-platform.test.ts @@ -21,6 +21,26 @@ const layer = AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [ ]) 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() @@ -70,12 +90,7 @@ describe("cross-platform PTY", () => { expect(pid).toBeGreaterThan(0) yield* pty.remove(info.id) - if (process.platform === "win32") { - const result = yield* Effect.promise(() => Bun.$`taskkill /pid ${pid} /f /t`.quiet().nothrow()) - expect(result.exitCode).toBe(128) - } else { - expect(() => process.kill(pid, 0)).toThrow() - } + expect(yield* Effect.promise(() => alive(pid))).toBe(false) }), ) }) From aadded4a3caf3799308c91c01d8e445bfb0f13d9 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 15:59:48 +0200 Subject: [PATCH 7/7] fix(pty): tolerate vanished process entries --- packages/core/src/kilocode/pty/termination.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/kilocode/pty/termination.ts b/packages/core/src/kilocode/pty/termination.ts index c9862adbd8b..70f7b6756ff 100644 --- a/packages/core/src/kilocode/pty/termination.ts +++ b/packages/core/src/kilocode/pty/termination.ts @@ -132,7 +132,8 @@ async function procTree() { entries .filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name)) .map(async (entry) => { - const stat = await readFile(`/proc/${entry.name}/stat`, "utf8") + 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]) }