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/calm-run-terminals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Run Agent Manager project scripts in the terminal selected by the existing toolbar dropdown. Agent Manager panel uses the named side terminal, while VS Code terminal retains the integrated task flow.
179 changes: 179 additions & 0 deletions packages/core/src/kilocode/pty/termination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { spawn } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
import type { Proc } from "../../pty/pty"
import { Log } from "../../util/log"

const log = Log.create({ service: "pty.termination" })
const GRACE_MS = 200
const SPAWN_TIMEOUT_MS = 5_000

export type Process = Pick<Proc, "pid" | "onExit" | "kill">

export type Runtime = {
readonly platform: NodeJS.Platform
readonly taskkill: (
file: string,
args: string[],
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
) => Promise<boolean>
readonly tree: () => Promise<Array<{ pid: number; parent: number }>>
readonly alive: (pid: number) => boolean
readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void
readonly sleep: (ms: number) => Promise<void>
}

const runtime: Runtime = {
platform: process.platform,
taskkill,
tree,
alive: (pid) => {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
},
signal: (pid, signal) => process.kill(pid, signal),
sleep,
}

function direct(proc: Process, signal?: "SIGTERM" | "SIGKILL") {
try {
proc.kill(signal)
} catch (err) {
log.warn("failed to kill PTY directly", { err, pid: proc.pid, signal })
}
}

function descendants(root: number, rows: Array<{ pid: number; parent: number }>) {
const children = new Map<number, number[]>()
for (const row of rows) {
const list = children.get(row.parent) ?? []
list.push(row.pid)
children.set(row.parent, list)
}
const seen = new Set<number>()
const collect = (pid: number): number[] => {
const result: number[] = []
for (const child of children.get(pid) ?? []) {
if (seen.has(child)) continue
seen.add(child)
result.push(...collect(child), child)
}
return result
}
return collect(root)
}

async function family(root: number, input: Runtime) {
const rows = await input.tree().catch((err) => {
log.debug("failed to inspect PTY process tree", { err, pid: root })
return []
})
return [...descendants(root, rows), root]
}

function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", input: Runtime) {
for (const pid of pids) {
let sent = false
for (const target of [-pid, pid]) {
try {
input.signal(target, value)
sent = true
} catch (err) {
log.debug("failed to signal PTY process", { err, pid: target, signal: value })
}
}
if (pid === proc.pid && !sent) direct(proc, value)
}
}

async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) {
Comment thread
marius-kilocode marked this conversation as resolved.
return await new Promise<Array<{ pid: number; parent: number }>>((resolve) => {
try {
const child = spawn(file, args, {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
timeout: SPAWN_TIMEOUT_MS,
killSignal: "SIGKILL",
})
const chunks: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk))
child.once("error", () => resolve([]))
child.once("close", (code) => {
if (code !== 0) return resolve([])
const rows = Buffer.concat(chunks)
.toString("utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => line.trim().split(/\s+/).map(Number))
.filter(([pid, parent]) => Number.isSafeInteger(pid) && Number.isSafeInteger(parent))
.map(([pid, parent]) => ({ pid: pid!, parent: parent! }))
resolve(rows)
})
} catch {
resolve([])
}
})
}

async function taskkill(
file: string,
args: string[],
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
) {
return await new Promise<boolean>((resolve) => {
try {
const child = spawn(file, args, opts)
child.once("exit", (code) => resolve(code === 0))
child.once("error", (err) => {
log.warn("taskkill failed", { err })
resolve(false)
})
} catch (err) {
log.warn("failed to start taskkill", { err })
resolve(false)
}
})
}

export async function terminate(proc: Process, input: Runtime = runtime): Promise<void> {
const state = { exited: false }
const listener = proc.onExit(() => {
state.exited = true
})
try {
if (!proc.pid) {
direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
return
}

if (input.platform === "win32") {
const killed = await input.taskkill("taskkill", ["/pid", String(proc.pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
timeout: SPAWN_TIMEOUT_MS,
})
if (!killed && !state.exited) direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
return
}

const initial = await family(proc.pid, input)
signal(proc, initial, "SIGTERM", input)
await input.sleep(GRACE_MS)
const remaining = new Set(initial.filter(input.alive))
if (input.alive(proc.pid)) for (const pid of await family(proc.pid, input)) remaining.add(pid)
if (remaining.size > 0) {
signal(proc, [...remaining], "SIGKILL", input)
await input.sleep(GRACE_MS)
}
} finally {
listener.dispose()
}
}

export * as KiloPtyTermination from "./termination"
63 changes: 38 additions & 25 deletions packages/core/src/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema" // kilocode_change
import { Shell } from "./shell"
import { lazy } from "./util/lazy"
import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change
import { KiloPtyTermination } from "./kilocode/pty/termination" // kilocode_change

const BUFFER_LIMIT = 1024 * 1024 * 2
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
Expand All @@ -35,6 +36,7 @@ type Active = {
cursor: number
subscribers: Map<object, Subscriber>
listeners: Disp[]
stopping: boolean // kilocode_change
}

export const Info = Schema.Struct({
Expand Down Expand Up @@ -83,6 +85,8 @@ export type AttachInput = {
readonly onData: (chunk: string) => void
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
readonly onEnd: (event: { exitCode?: number }) => void
// Canonical routes can replay retained output after exit; legacy callers retain the former error.
readonly allowExited?: boolean // kilocode_change
}

export type Attachment = {
Expand Down Expand Up @@ -147,23 +151,25 @@ export const layer = Layer.effect(
session.subscribers.clear()
}

function teardown(session: Active) {
// kilocode_change start - terminate the complete PTY tree before reporting removal.
async function teardown(session: Active) {
session.stopping = true
if (session.info.status === "running") await KiloPtyTermination.terminate(session.process)
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
if (session.info.status === "running") {
try {
session.process.kill()
} catch {}
}
notifyEnd(session, {})
notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {})
}

yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) teardown(session)
sessions.clear()
exitOrder.length = 0
}),
// kilocode_change end

yield* Effect.addFinalizer(
() =>
// kilocode_change start - wait for process-tree termination during async service teardown.
Effect.promise(async () => {
await Promise.all(Array.from(sessions.values()).map(teardown))
sessions.clear()
exitOrder.length = 0
}),
// kilocode_change end
)

const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
Expand All @@ -173,14 +179,18 @@ export const layer = Layer.effect(
})

const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
// kilocode_change start - removal and its deleted event are one uninterruptible lifecycle transition.
yield* Effect.gen(function* () {
const session = sessions.get(id)
if (!session) return
yield* Effect.logInfo("removing session", { id })
yield* Effect.promise(() => teardown(session))
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* events.publish(Event.Deleted, { id: session.info.id })
}).pipe(Effect.uninterruptible)
// kilocode_change end
})

const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
Expand All @@ -204,9 +214,10 @@ export const layer = Layer.effect(
args: input.args ? [...input.args] : undefined,
cwd: input.cwd,
})
const implicit = !resolved.command
const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
const base = resolved.args ?? []
const args = Shell.login(command) ? [...base, "-l"] : [...base]
const args = implicit && Shell.login(command) ? [...base, "-l"] : [...base]
const cwd = resolved.cwd || location.directory
// kilocode_change end
const env = {
Expand Down Expand Up @@ -246,6 +257,7 @@ export const layer = Layer.effect(
cursor: 0,
subscribers: new Map(),
listeners: [],
stopping: false, // kilocode_change
}
sessions.set(id, session)
session.listeners.push(
Expand All @@ -269,7 +281,7 @@ export const layer = Layer.effect(
session.bufferCursor += excess
}),
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
if (session.info.status === "exited" || session.stopping) return // kilocode_change
session.info.status = "exited"
session.info.exitCode = exitCode
notifyEnd(session, { exitCode })
Expand Down Expand Up @@ -309,7 +321,7 @@ export const layer = Layer.effect(

const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
if (session.info.status !== "running" && !input.allowExited) return yield* new ExitedError({ ptyID: id }) // kilocode_change
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
const token = {}
const subscriber: Subscriber = {
Expand All @@ -318,6 +330,7 @@ export const layer = Layer.effect(
active: false,
detached: false,
pending: [],
end: session.info.status === "exited" ? { exitCode: session.info.exitCode } : undefined, // kilocode_change
Comment thread
marius-kilocode marked this conversation as resolved.
}
session.subscribers.set(token, subscriber)
const start = session.bufferCursor
Expand Down
Loading
Loading