diff --git a/.changeset/calm-run-terminals.md b/.changeset/calm-run-terminals.md new file mode 100644 index 00000000000..df2a4ace3d7 --- /dev/null +++ b/.changeset/calm-run-terminals.md @@ -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. diff --git a/packages/core/src/kilocode/pty/termination.ts b/packages/core/src/kilocode/pty/termination.ts new file mode 100644 index 00000000000..bb1fd30be7f --- /dev/null +++ b/packages/core/src/kilocode/pty/termination.ts @@ -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 + +export type Runtime = { + readonly platform: NodeJS.Platform + readonly taskkill: ( + file: string, + args: string[], + opts: { stdio: "ignore"; windowsHide: true; timeout: number }, + ) => Promise + readonly tree: () => Promise> + readonly alive: (pid: number) => boolean + readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void + readonly sleep: (ms: number) => Promise +} + +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() + for (const row of rows) { + const list = children.get(row.parent) ?? [] + list.push(row.pid) + children.set(row.parent, list) + } + const seen = new Set() + 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="]) { + return await new Promise>((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((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 { + 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" diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 28e48f6b051..0157c0d8d1d 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -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. @@ -35,6 +36,7 @@ type Active = { cursor: number subscribers: Map listeners: Disp[] + stopping: boolean // kilocode_change } export const Info = Schema.Struct({ @@ -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 = { @@ -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) { @@ -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) { @@ -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 = { @@ -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( @@ -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 }) @@ -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 = { @@ -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 } session.subscribers.set(token, subscriber) const start = session.bufferCursor diff --git a/packages/core/test/kilocode/pty-termination.test.ts b/packages/core/test/kilocode/pty-termination.test.ts new file mode 100644 index 00000000000..a6162cfbe0e --- /dev/null +++ b/packages/core/test/kilocode/pty-termination.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test" +import { KiloPtyTermination } from "../../src/kilocode/pty/termination" + +function fake(pid = 123) { + const calls: Array = [] + const proc: KiloPtyTermination.Process = { + pid, + onExit: () => ({ dispose() {} }), + kill: (signal) => calls.push(signal), + } + return { proc, calls } +} + +function runtime( + platform: NodeJS.Platform, + input: { + taskkill?: boolean + signal?: "throw" + tree?: Array<{ pid: number; parent: number }> + } = {}, +) { + const tasks: Array<{ + file: string + args: string[] + opts: { stdio: "ignore"; windowsHide: true; timeout: number } + }> = [] + const signals: Array<{ pid: number; signal: "SIGTERM" | "SIGKILL" }> = [] + const sleeps: number[] = [] + const value: KiloPtyTermination.Runtime = { + platform, + taskkill: async (file, args, opts) => { + tasks.push({ file, args, opts }) + return input.taskkill ?? true + }, + tree: async () => input.tree ?? [], + alive: () => true, + signal: (pid, signal) => { + signals.push({ pid, signal }) + if (input.signal === "throw") throw new Error("process group unavailable") + }, + sleep: async (ms) => { + sleeps.push(ms) + }, + } + return { value, tasks, signals, sleeps } +} + +describe("pty process-tree termination", () => { + test("uses hidden taskkill for Windows process trees", async () => { + const item = fake(42) + const input = runtime("win32") + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(input.tasks).toEqual([ + { + file: "taskkill", + args: ["/pid", "42", "/f", "/t"], + opts: { stdio: "ignore", windowsHide: true, timeout: 5_000 }, + }, + ]) + expect(input.signals).toEqual([]) + expect(item.calls).toEqual([]) + expect(input.sleeps).toEqual([200]) + }) + + test("signals POSIX process groups before escalating", async () => { + const item = fake(42) + const input = runtime("linux") + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(input.signals).toEqual([ + { pid: -42, signal: "SIGTERM" }, + { pid: 42, signal: "SIGTERM" }, + { pid: -42, signal: "SIGKILL" }, + { pid: 42, signal: "SIGKILL" }, + ]) + expect(item.calls).toEqual([]) + expect(input.sleeps).toEqual([200, 200]) + }) + + test("falls back to direct PTY signals when a process group is unavailable", async () => { + const item = fake(42) + const input = runtime("darwin", { signal: "throw" }) + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(item.calls).toEqual(["SIGTERM", "SIGKILL"]) + }) + + test("signals descendants that run in separate process groups", async () => { + const item = fake(42) + const input = runtime("linux", { + tree: [ + { pid: 43, parent: 42 }, + { pid: 44, parent: 43 }, + ], + }) + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(input.signals).toContainEqual({ pid: -44, signal: "SIGTERM" }) + expect(input.signals).toContainEqual({ pid: 44, signal: "SIGKILL" }) + expect(input.signals).toContainEqual({ pid: -43, signal: "SIGTERM" }) + expect(input.signals).toContainEqual({ pid: 43, signal: "SIGKILL" }) + }) +}) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 6b78ec4d13c..13184a8a12b 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -127,6 +127,43 @@ describe("pty", () => { }), ) + // kilocode_change start - explicit commands must not acquire implicit login-shell arguments. + ptyTest("preserves explicit command arguments", () => + Effect.gen(function* () { + const args = ["-c", 'printf "<%s>" "$0"; sleep 5'] + const info = yield* createPty("sh", args) + expect(info.args).toEqual(args) + + const attached = yield* attachCollecting(info.id) + expect(yield* waitForOutput(attached.output, "")).toContain("") + }), + ) + + ptyTest("terminates background descendants outside the shell process group", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* createPty("sh", ["-c", 'sleep 30 & printf "" "$!"; wait']) + const attached = yield* attachCollecting(info.id) + const output = yield* waitForOutput(attached.output, ">") + const match = output.match(//) + expect(match?.[1]).toBeDefined() + const pid = Number(match?.[1]) + + yield* pty.remove(info.id) + yield* Effect.sleep("100 millis") + const alive = yield* Effect.sync(() => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + }) + expect(alive).toBe(false) + }), + ) + // kilocode_change end + ptyTest("replays buffered output and streams live output to attachments", () => Effect.gen(function* () { const pty = yield* Pty.Service @@ -201,6 +238,31 @@ describe("pty", () => { expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id }) }), ) + + // kilocode_change start - canonical attachments replay retained exited output, then end without accepting input. + ptyTest("replays exited output and ends when enabled", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const events = yield* subscribePtyEvents() + const info = yield* createPty("sh", ["-c", 'printf "replayed"; exit 7']) + expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"]) + + const ended = yield* Deferred.make<{ exitCode?: number }>() + const attachment = yield* pty.attach(info.id, { + allowExited: true, + onData: () => {}, + onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)), + }) + expect(attachment.replay).toContain("replayed") + + attachment.write("ignored") + yield* pty.remove(info.id) + attachment.activate() + expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 }) + attachment.detach() + }), + ) + // kilocode_change end }) const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 9651c14b2b2..1d5e2554c9e 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -339,10 +339,12 @@ Two extra variables are injected into the script's environment: ### Using the run button -- **Run:** Click the play button in the toolbar or press `Cmd+E` (macOS) / `Ctrl+E` (Windows/Linux). Output appears in a dedicated VS Code task panel. +- **Run:** Click the play button in the toolbar or press `Cmd+E` (macOS) / `Ctrl+E` (Windows/Linux). Output appears in a named `Run` tab in the Agent Manager terminal panel and remains available after the script exits. - **Stop:** Click the stop button (same position) or press `Cmd+E` again while running. - **Configure:** Click the dropdown arrow next to the run button and select "Configure run script" to open the script in your editor. +The terminal destination dropdown in the Agent Manager toolbar also controls where the script runs. **Agent Manager panel** uses the named side terminal, while **VS Code terminal** runs it as a task in the integrated terminal. The integrated terminal option is kept for comparison and will be removed in a future release. + ## Session State and Persistence Agent Manager state is persisted in `.kilo/agent-manager.json`. It stores worktrees, sections, session tabs, ordering, collapsed state, diff preferences, and cached PR metadata. Git branches and worktree directories remain on disk separately. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index e8e668b4b50..6592d486194 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -22,9 +22,9 @@ import { SessionTerminalManager } from "./SessionTerminalManager" import { createTerminalHost } from "./terminal-host" import { TerminalRouter } from "./terminal-routing" import { executeVscodeTask } from "./task-runner" -import { startVscodeRunTask } from "./run/task" import { RunController } from "./run/controller" import { handleRunMessage } from "./run/message" +import { createRunController, createScriptTerminalRuntime } from "./script-terminal-runtime" import { forkSession } from "./fork-session" import { AgentManagerVisiblePresence } from "./am-visible-presence" import { continueInWorktree } from "./continue-in-worktree" @@ -64,6 +64,7 @@ export class AgentManagerProvider implements Disposable { private importer: WorktreeImporter private terminalManager: SessionTerminalManager private terminalRouter: TerminalRouter + private scripts: ReturnType private run: RunController private stateReady: Promise | undefined private statsPoller: GitStatsPoller @@ -111,19 +112,25 @@ export class AgentManagerProvider implements Disposable { post: (msg) => this.postToWebview(msg), getTerminalFont: () => readTerminalFont(), }) + this.scripts = createScriptTerminalRuntime({ + connection: this.connectionService, + output: this.outputChannel, + post: (message) => this.postToWebview(message), + }) this.unsubFont = watchTerminalFont((font) => { this.postToWebview({ type: "agentManager.terminal.fontChanged", font }) + this.scripts.manager.snapshot() }) this.unsubDestination = watchTerminalDestination((destination) => { this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination }) }) - this.run = new RunController({ + this.run = createRunController({ + manager: this.scripts.manager, root: () => this.getRoot(), state: () => this.getStateManager(), open: (file) => this.host.openDocument(file), - start: startVscodeRunTask, - post: (status) => this.postToWebview({ type: "agentManager.runStatus", ...status }), - error: (message) => this.postToWebview({ type: "error", message }), + trusted: () => this.host.isTrusted(), + post: (message) => this.postToWebview(message), log: (msg) => this.outputChannel.appendLine(`[RunScript] ${msg}`), refresh: () => this.pushState(), }) @@ -412,6 +419,7 @@ export class AgentManagerProvider implements Disposable { if (diff !== undefined) return diff const bridge = this.onBridgeMessage(m) if (bridge !== undefined) return bridge + if (this.scripts.manager.intercept(m)) return null if (this.terminalRouter.handle(m)) return null return msg @@ -732,6 +740,7 @@ export class AgentManagerProvider implements Disposable { // the panel itself is disposed. In-flight creates from the dying // instance are reaped by the router's generation guard. void this.terminalRouter.dispose() + this.scripts.manager.snapshot() void this.stateReady ?.then(() => { // When the folder is not a git repo (or has no folder open), @@ -1023,11 +1032,16 @@ export class AgentManagerProvider implements Disposable { this.log(`Worktree ${worktreeId} not found in state`) return null } + this.statsPoller.skipWorktree(worktreeId) + await this.run.remove(worktreeId) + if (!(await this.scripts.manager.clear("run", worktreeId))) { + this.statsPoller.unskipWorktree(worktreeId) + this.postToWebview({ type: "error", message: "Failed to stop the Run script before deleting the worktree" }) + return null + } // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree. // Pre-emptive skip covers any in-flight poll that already captured getWorktrees(). - this.statsPoller.skipWorktree(worktreeId) this.prBridge.remove(worktreeId) - this.run.remove(worktreeId) this.naming.forget(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { @@ -1062,6 +1076,11 @@ export class AgentManagerProvider implements Disposable { return null } + await this.run.remove(worktreeId) + if (!(await this.scripts.manager.clear("run", worktreeId))) { + this.postToWebview({ type: "error", message: "Failed to stop the Run script before removing the worktree" }) + return null + } this.naming.forget(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { @@ -1924,6 +1943,7 @@ export class AgentManagerProvider implements Disposable { this.unsubStatus?.() this.unsubFont?.() this.unsubDestination?.() + await this.scripts.dispose() this.orchestration.dispose() this.visiblePresence.clear() this.diffs.stop() diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index ce386a858a1..405557d50d0 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -102,6 +102,10 @@ export class GitStatsPoller { this.skipWorktreeIds.add(id) } + unskipWorktree(id: string): void { + this.skipWorktreeIds.delete(id) + } + setEnabled(enabled: boolean): void { if (enabled) { if (this.active) return diff --git a/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts new file mode 100644 index 00000000000..91450ef452c --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts @@ -0,0 +1,395 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { TerminalFont } from "./terminal-font" +import type { RunHandle } from "./run/manager" + +type ScriptTerminalKind = "run" +type ScriptTerminalState = "running" | "stopping" | "exited" | "failed" + +interface ScriptTerminalConfig { + worktreeId: string + command: string + args: string[] + cwd: string + env: Record +} + +interface ScriptTerminalExit { + exitCode?: number + stopped?: boolean + error?: string +} + +export interface ScriptTerminalView { + terminalId: string + /** null for the LOCAL workspace; RunController retains its internal "local" key. */ + worktreeId: string | null + kind: ScriptTerminalKind + title: "Run" + wsUrl: string + state: ScriptTerminalState + exitCode?: number + font: TerminalFont +} + +interface ScriptTerminalDeps { + getClient(): KiloClient + getClientAsync(directory: string): Promise + buildWsUrl(ptyID: string, cwd: string): string + getTerminalFont(): TerminalFont + emit(terminals: ScriptTerminalView[]): void + closed(terminalId: string): void + log(msg: string): void +} + +interface Entry { + key: string + kind: ScriptTerminalKind + terminalId: string + ptyID: string + worktreeId: string + cwd: string + wsUrl: string + state: ScriptTerminalState + exitCode?: number + done: (exit: ScriptTerminalExit) => void + finished: boolean + closing?: Promise +} + +interface TerminalMessage { + type: string + terminalId?: unknown + cols?: unknown + rows?: unknown +} + +function message(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} + +function missing(error: unknown): boolean { + if (!error || typeof error !== "object") return false + const value = error as Record + if (value.status === 404 || value._tag === "PtyNotFoundError") return true + if (!value.data || typeof value.data !== "object") return false + const data = value.data as Record + return data.status === 404 || data._tag === "PtyNotFoundError" +} + +function key(kind: ScriptTerminalKind, worktreeId: string): string { + return `${kind}:${worktreeId}` +} + +function terminalId(): string { + return `script:${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +/** + * Owns extension-host script PTYs independently from webview terminal routing. + * Exited records stay available for output replay until the user closes them. + */ +export class ScriptTerminalManager { + private readonly entries = new Map() + private readonly terminals = new Map() + private readonly ptys = new Map() + + constructor(private readonly deps: ScriptTerminalDeps) {} + + async start( + kind: ScriptTerminalKind, + config: ScriptTerminalConfig, + done: (exit: ScriptTerminalExit) => void, + ): Promise { + const id = key(kind, config.worktreeId) + const prior = this.entries.get(id) + if (prior) { + if (prior.state === "running" || prior.state === "stopping") throw new Error("Run terminal is already active") + await this.remove(prior, false) + if (this.entries.has(id)) throw new Error("Failed to remove previous Run terminal") + } + + const client = await this.deps.getClientAsync(config.cwd).catch((error) => { + const detail = message(error) + this.deps.log(`Run terminal create failed: ${detail}`) + throw new Error(detail) + }) + const created = await client.v2.pty + .create({ + location: { directory: config.cwd }, + command: config.command, + args: config.args, + cwd: config.cwd, + env: config.env, + title: "Run", + }) + .catch((error) => { + const detail = message(error) + this.deps.log(`Run terminal create failed: ${detail}`) + throw new Error(detail) + }) + const pty = created.data?.data + if (created.error || !pty) { + const detail = message(created.error ?? "unknown error") + this.deps.log(`Run terminal create failed: ${detail}`) + throw new Error(`Failed to create Run terminal: ${detail}`) + } + + const wsUrl = await this.url(client, pty.id, config.cwd) + const entry: Entry = { + key: id, + kind, + terminalId: terminalId(), + ptyID: pty.id, + worktreeId: config.worktreeId, + cwd: config.cwd, + wsUrl, + state: "running", + done, + finished: false, + } + this.entries.set(entry.key, entry) + this.terminals.set(entry.terminalId, entry) + this.ptys.set(entry.ptyID, entry) + this.emit() + + await this.reconcile(entry, client) + + return { + stop: () => this.stop(entry), + } + } + + /** Return true only for close/resize messages owned by a script terminal. */ + intercept(msg: TerminalMessage): boolean { + const id = msg.terminalId + if (typeof id !== "string" || !this.terminals.has(id)) return false + if (msg.type === "agentManager.terminal.close") { + void this.close(id).then((closed) => { + if (closed) this.deps.closed(id) + }) + return true + } + if (msg.type !== "agentManager.terminal.resize") return false + if (typeof msg.cols !== "number" || typeof msg.rows !== "number") return true + void this.resize(id, msg.cols, msg.rows) + return true + } + + exited(ptyID: string, exitCode: number): void { + const entry = this.ptys.get(ptyID) + if (!entry) return + this.finishExited(entry, exitCode) + } + + deleted(ptyID: string): void { + const entry = this.ptys.get(ptyID) + if (!entry) return + const state = entry.state + this.drop(entry) + this.emit() + if (state === "stopping") { + this.done(entry, { stopped: true }) + return + } + if (state === "running") this.done(entry, { error: "Run terminal was removed before it exited" }) + } + + snapshot(): void { + this.emit() + } + + owns(ptyID: string): boolean { + return this.ptys.has(ptyID) + } + + async sync(): Promise { + await Promise.all( + [...this.entries.values()].map(async (entry) => { + const client = await this.deps.getClientAsync(entry.cwd).catch((error) => { + this.deps.log(`Failed to reconnect Run terminal: ${message(error)}`) + return undefined + }) + if (client) await this.reconcile(entry, client) + }), + ) + } + + async clear(kind: ScriptTerminalKind, worktreeId: string): Promise { + const entry = this.entries.get(key(kind, worktreeId)) + if (!entry) return true + return this.close(entry.terminalId) + } + + async close(terminalId: string): Promise { + const entry = this.terminals.get(terminalId) + if (!entry) return true + if (entry.state === "running") { + await this.stop(entry) + return !this.terminals.has(terminalId) + } + if (entry.state === "stopping") { + await entry.closing + return !this.terminals.has(terminalId) + } + await this.remove(entry, false) + return !this.terminals.has(terminalId) + } + + async resize(terminalId: string, cols: number, rows: number): Promise { + const entry = this.terminals.get(terminalId) + if (!entry) return + try { + const client = this.deps.getClient() + const result = await client.v2.pty.update({ + ptyID: entry.ptyID, + location: { directory: entry.cwd }, + size: { cols, rows }, + }) + if (!result.error) return + this.deps.log(`Run terminal resize failed (${terminalId}): ${message(result.error)}`) + } catch (error) { + this.deps.log(`Run terminal resize failed (${terminalId}): ${message(error)}`) + } + } + + async dispose(): Promise { + await Promise.all([...this.terminals.keys()].map((terminalId) => this.close(terminalId))) + } + + private async reconcile(entry: Entry, client: KiloClient): Promise { + if (!this.current(entry)) return + try { + const result = await client.v2.pty.get({ ptyID: entry.ptyID, location: { directory: entry.cwd } }) + const pty = result.data?.data + if (result.error || !pty) { + this.missing(entry, `Run terminal is no longer available: ${message(result.error ?? "unknown error")}`) + return + } + if (pty.status === "exited") this.finishExited(entry, pty.exitCode ?? 0) + } catch (error) { + this.deps.log(`Failed to read Run terminal: ${message(error)}`) + } + } + + private async stop(entry: Entry): Promise { + if (!this.current(entry)) return + if (entry.state === "stopping") { + await entry.closing + return + } + if (entry.state === "exited" || entry.state === "failed") { + await this.remove(entry, false) + return + } + entry.state = "stopping" + this.emit() + await this.remove(entry, true) + } + + private remove(entry: Entry, stopped: boolean): Promise { + if (entry.closing) return entry.closing + const task = this.removeEntry(entry, stopped) + entry.closing = task + void task.finally(() => { + if (this.current(entry) && entry.closing === task) entry.closing = undefined + }) + return task + } + + private async removeEntry(entry: Entry, stopped: boolean): Promise { + try { + const client = await this.deps.getClientAsync(entry.cwd) + const result = await client.v2.pty.remove({ ptyID: entry.ptyID, location: { directory: entry.cwd } }) + if (result.error) { + if (missing(result.error)) { + this.drop(entry) + this.emit() + if (stopped) this.done(entry, { stopped: true }) + return + } + this.failed(entry, `Failed to remove Run terminal: ${message(result.error)}`) + return + } + this.drop(entry) + this.emit() + if (stopped) this.done(entry, { stopped: true }) + } catch (error) { + this.failed(entry, `Failed to remove Run terminal: ${message(error)}`) + } + } + + private async url(client: KiloClient, ptyID: string, cwd: string): Promise { + try { + return this.deps.buildWsUrl(ptyID, cwd) + } catch (error) { + this.deps.log(`Failed to build Run terminal URL: ${message(error)}`) + try { + const result = await client.v2.pty.remove({ ptyID, location: { directory: cwd } }) + if (result.error) this.deps.log(`Failed to remove Run terminal after URL failure: ${message(result.error)}`) + } catch (cleanup) { + this.deps.log(`Failed to remove Run terminal after URL failure: ${message(cleanup)}`) + } + throw error + } + } + + private finishExited(entry: Entry, exitCode: number): void { + if (!this.current(entry) || entry.state === "exited") return + entry.state = "exited" + entry.exitCode = exitCode + this.emit() + this.done(entry, { exitCode }) + } + + private failed(entry: Entry, error: string): void { + if (!this.current(entry)) return + this.deps.log(error) + entry.state = "failed" + this.emit() + this.done(entry, { error }) + } + + private missing(entry: Entry, error: string): void { + if (!this.current(entry)) return + this.deps.log(error) + this.drop(entry) + this.emit() + this.done(entry, { error }) + } + + private done(entry: Entry, exit: ScriptTerminalExit): void { + if (entry.finished) return + entry.finished = true + entry.done(exit) + } + + private drop(entry: Entry): void { + if (!this.current(entry)) return + this.entries.delete(entry.key) + this.terminals.delete(entry.terminalId) + this.ptys.delete(entry.ptyID) + } + + private current(entry: Entry): boolean { + return this.entries.get(entry.key) === entry + } + + private emit(): void { + const terminals: ScriptTerminalView[] = [] + for (const entry of this.entries.values()) { + const terminal: ScriptTerminalView = { + terminalId: entry.terminalId, + worktreeId: entry.worktreeId === "local" ? null : entry.worktreeId, + kind: entry.kind, + title: "Run", + wsUrl: entry.wsUrl, + state: entry.state, + font: this.deps.getTerminalFont(), + } + if (entry.exitCode !== undefined) terminal.exitCode = entry.exitCode + terminals.push(terminal) + } + this.deps.emit(terminals) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts index 7766476185b..92c6c1a6612 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -71,6 +71,7 @@ function createMockHost(): Host { return { openPanel: vi.fn(), workspacePath: () => "/repo", + isTrusted: () => true, autoBranchNaming: () => ({ enabled: true, prefix: "" }), showError: vi.fn(), openDocument: vi.fn().mockResolvedValue(undefined), @@ -78,7 +79,10 @@ function createMockHost(): Host { openFolder: vi.fn(), createOutput: () => ({ appendLine: vi.fn(), dispose: vi.fn() }) as OutputHandle, extensionKeybindings: () => [], + copyToClipboard: vi.fn(), capture: vi.fn(), + openExternal: vi.fn(), + refreshGit: vi.fn(), dispose: vi.fn(), } } @@ -102,6 +106,7 @@ function createHarness() { prBridge: { handleMessage: ReturnType } activeSessionId: string | undefined naming: { prompt: ReturnType } + scripts: { intercept: ReturnType; snapshot: ReturnType } terminalRouter: { handle: ReturnType } stateReady: Promise | undefined contextTarget: ReturnType @@ -125,6 +130,7 @@ function createHarness() { manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) } manager.activeSessionId = undefined manager.naming = { prompt: vi.fn() } + manager.scripts = { intercept: vi.fn().mockReturnValue(false), snapshot: vi.fn() } manager.terminalRouter = { handle: vi.fn().mockReturnValue(false) } manager.stateReady = Promise.resolve() manager.contextTarget = vi.fn() diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index ed8b1f2f633..2916efbf62b 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -104,6 +104,9 @@ export interface Host { /** Get the workspace/project root path. */ workspacePath(): string | undefined + /** Whether the workspace permits executing configured scripts. */ + isTrusted(): boolean + /** Read the user's automatic branch naming preferences. */ autoBranchNaming(): { enabled: boolean; prefix: string } diff --git a/packages/kilo-vscode/src/agent-manager/run/controller.ts b/packages/kilo-vscode/src/agent-manager/run/controller.ts index b5db0320e77..614436975aa 100644 --- a/packages/kilo-vscode/src/agent-manager/run/controller.ts +++ b/packages/kilo-vscode/src/agent-manager/run/controller.ts @@ -4,8 +4,10 @@ import { getShellEnvironment } from "../shell-env" import { RunScriptManager, type RunHandle, type RunStatus } from "./manager" import { RunScriptService } from "./service" import type { WorktreeStateManager } from "../WorktreeStateManager" +import type { RunTerminalDestination } from "./destination" export interface RunTaskConfig { + destination: RunTerminalDestination worktreeId: string branch: string command: string @@ -14,11 +16,13 @@ export interface RunTaskConfig { env: Record } -interface TaskExit { +export interface RunTaskExit { exitCode?: number + stopped?: boolean + error?: string } -type StartTask = (config: RunTaskConfig, done: (exit: TaskExit) => void) => Promise +export type StartTask = (config: RunTaskConfig, done: (exit: RunTaskExit) => void) => Promise interface Options { root: () => string | undefined @@ -60,7 +64,7 @@ export class RunController { this.opts.refresh?.() } - async run(worktreeId: string): Promise { + async run(worktreeId: string, destination: RunTerminalDestination): Promise { const status = this.manager.status(worktreeId) if (status.state !== "idle") { this.stop(worktreeId) @@ -109,18 +113,19 @@ export class RunController { } const start = () => - this.opts.start({ worktreeId, branch, command: script.command, args: script.args, cwd, env }, (exit) => - this.manager.finish(worktreeId, { exitCode: exit.exitCode }), + this.opts.start( + { destination, worktreeId, branch, command: script.command, args: script.args, cwd, env }, + (exit) => this.manager.finish(worktreeId, exit), ) await this.manager.start(worktreeId, start) } stop(worktreeId: string): void { - this.manager.stop(worktreeId) + void this.manager.stop(worktreeId) } - remove(worktreeId: string): void { - this.manager.remove(worktreeId) + remove(worktreeId: string): Promise { + return this.manager.remove(worktreeId) } dispose(): void { diff --git a/packages/kilo-vscode/src/agent-manager/run/destination.ts b/packages/kilo-vscode/src/agent-manager/run/destination.ts new file mode 100644 index 00000000000..5945da16b7a --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/run/destination.ts @@ -0,0 +1,16 @@ +/** + * Where the Agent Manager Run button executes the project run script. + * + * The Agent Manager terminal dropdown owns this choice per panel. + * "agentManager" runs through the canonical PTY service in the embedded + * side terminal. "vscode" is the legacy integrated terminal task path, + * kept for comparison while the embedded path proves itself. Remove the + * "vscode" dropdown option, `run/task.ts`, and the integrated branch below + * together once the embedded path is the only one. + */ + +export type RunTerminalDestination = "agentManager" | "vscode" + +export function pickRunStart(destination: RunTerminalDestination, embedded: T, integrated: T): T { + return destination === "vscode" ? integrated : embedded +} diff --git a/packages/kilo-vscode/src/agent-manager/run/manager.ts b/packages/kilo-vscode/src/agent-manager/run/manager.ts index a1b92e35c9a..60af10cfa5c 100644 --- a/packages/kilo-vscode/src/agent-manager/run/manager.ts +++ b/packages/kilo-vscode/src/agent-manager/run/manager.ts @@ -4,6 +4,7 @@ export interface RunStatus { worktreeId: string state: RunState exitCode?: number + stopped?: boolean signal?: string startedAt?: string finishedAt?: string @@ -11,17 +12,21 @@ export interface RunStatus { } export interface RunHandle { - stop(): void + stop(): void | Promise dispose?(): void } interface Entry { status: RunStatus handle?: RunHandle + task?: Promise + released?: boolean + stopping?: Promise } interface FinishOptions { exitCode?: number + stopped?: boolean signal?: string error?: string } @@ -42,6 +47,7 @@ export class RunScriptManager { ) {} async start(worktreeId: string, start: () => Promise): Promise { + this.removed.delete(worktreeId) const current = this.entries.get(worktreeId) if (current && current.status.state !== "idle") return false @@ -56,21 +62,25 @@ export class RunScriptManager { this.emit(entry.status) try { - const handle = await start() + const task = start() + entry.task = task + const handle = await task const latest = this.entries.get(worktreeId) if (latest !== entry) { - handle.dispose?.() + await this.release(worktreeId, entry, handle, this.removed.has(worktreeId)) return true } entry.handle = handle - if (entry.status.state === "stopping") handle.stop() + if (entry.status.state === "stopping") { + void this.halt(worktreeId, entry, handle) + } } catch (error) { this.finish(worktreeId, { error: message(error) }) } return true } - stop(worktreeId: string): void { + async stop(worktreeId: string): Promise { const entry = this.entries.get(worktreeId) if (!entry || entry.status.state === "idle" || entry.status.state === "stopping") return @@ -81,11 +91,7 @@ export class RunScriptManager { this.emit(entry.status) if (!entry.handle) return - try { - entry.handle.stop() - } catch (error) { - this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`) - } + await this.halt(worktreeId, entry, entry.handle) } finish(worktreeId: string, opts: FinishOptions = {}): void { @@ -100,6 +106,7 @@ export class RunScriptManager { } if (entry?.status.startedAt) status.startedAt = entry.status.startedAt if (opts.exitCode !== undefined) status.exitCode = opts.exitCode + if (opts.stopped) status.stopped = true if (opts.signal) status.signal = opts.signal if (opts.error) status.error = opts.error @@ -115,24 +122,57 @@ export class RunScriptManager { return [...this.entries.values()].map((entry) => entry.status) } - remove(worktreeId: string): void { + async remove(worktreeId: string): Promise { const entry = this.entries.get(worktreeId) - if (entry?.status.state !== "idle") this.stop(worktreeId) - this.entries.delete(worktreeId) this.removed.add(worktreeId) + this.entries.delete(worktreeId) + const handle = + entry?.handle ?? + (entry?.task + ? await entry.task.catch((error) => { + this.log(`Failed to start removed run script for ${worktreeId}: ${message(error)}`) + return undefined + }) + : undefined) + if (!entry || !handle) return + if (entry.status.state !== "idle") { + await this.release(worktreeId, entry, handle, true) + return + } + await this.release(worktreeId, entry, handle, false) } dispose(): void { - for (const entry of this.entries.values()) { - if (entry.status.state !== "idle") { - try { - entry.handle?.stop() - } catch (error) { - this.log(`Failed to stop run script during dispose: ${message(error)}`) - } - } - entry.handle?.dispose?.() + for (const [id, entry] of this.entries) { + this.removed.add(id) + if (!entry.handle || entry.released) continue + entry.released = true + if (entry.status.state !== "idle") void this.halt(id, entry, entry.handle) + entry.handle.dispose?.() } this.entries.clear() } + + private async release(worktreeId: string, entry: Entry, handle: RunHandle, stop: boolean): Promise { + if (entry.released) return + entry.released = true + if (stop) await this.halt(worktreeId, entry, handle) + handle.dispose?.() + } + + private halt(worktreeId: string, entry: Entry, handle: RunHandle): Promise { + if (entry.stopping) return entry.stopping + const task = (() => { + try { + return Promise.resolve(handle.stop()) + .then(() => undefined) + .catch((error) => this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`)) + } catch (error) { + this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`) + return Promise.resolve() + } + })() + entry.stopping = task + return task + } } diff --git a/packages/kilo-vscode/src/agent-manager/run/message.ts b/packages/kilo-vscode/src/agent-manager/run/message.ts index 1e8f21a936d..6b51050a3e3 100644 --- a/packages/kilo-vscode/src/agent-manager/run/message.ts +++ b/packages/kilo-vscode/src/agent-manager/run/message.ts @@ -7,7 +7,7 @@ export function handleRunMessage(run: RunController, msg: AgentManagerInMessage) return true } if (msg.type === "agentManager.runScript") { - void run.run(msg.worktreeId) + void run.run(msg.worktreeId, msg.destination) return true } if (msg.type === "agentManager.stopRunScript") { diff --git a/packages/kilo-vscode/src/agent-manager/run/task.ts b/packages/kilo-vscode/src/agent-manager/run/task.ts index 0835b216af3..664baf4753d 100644 --- a/packages/kilo-vscode/src/agent-manager/run/task.ts +++ b/packages/kilo-vscode/src/agent-manager/run/task.ts @@ -1,3 +1,11 @@ +/** + * Legacy integrated terminal Run adapter. + * + * Kept while the Agent Manager terminal dropdown offers the "VS Code + * terminal" option so both execution paths can be compared. Remove this + * file together with that dropdown option and the integrated `pickRunStart` + * branch. + */ import * as vscode from "vscode" import type { RunHandle } from "./manager" diff --git a/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts new file mode 100644 index 00000000000..da2f3be9bea --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts @@ -0,0 +1,81 @@ +import type { KiloConnectionService } from "../services/cli-backend" +import type { OutputHandle } from "./host" +import { ScriptTerminalManager } from "./ScriptTerminalManager" +import { buildScriptTerminalWsUrl } from "./script-terminal-url" +import { readTerminalFont } from "./terminal-font" +import type { AgentManagerOutMessage } from "./types" +import type { WorktreeStateManager } from "./WorktreeStateManager" +import { RunController } from "./run/controller" +import { pickRunStart } from "./run/destination" +import { startVscodeRunTask } from "./run/task" + +interface Input { + connection: KiloConnectionService + output: OutputHandle + post(message: AgentManagerOutMessage): void +} + +export function createScriptTerminalRuntime(input: Input) { + const manager = new ScriptTerminalManager({ + getClient: () => input.connection.getClient(), + getClientAsync: (directory) => input.connection.getClientAsync(directory), + buildWsUrl: (ptyID, cwd) => { + const config = input.connection.getServerConfig() + if (!config) throw new Error("Not connected to CLI backend") + return buildScriptTerminalWsUrl(config, ptyID, cwd) + }, + getTerminalFont: () => readTerminalFont(), + emit: (terminals) => input.post({ type: "agentManager.scriptTerminals", terminals }), + closed: (terminalId) => input.post({ type: "agentManager.terminal.closed", terminalId }), + log: (msg) => input.output.appendLine(`[RunScript] ${msg}`), + }) + const event = input.connection.onEventFiltered( + (value) => (value.type === "pty.exited" || value.type === "pty.deleted") && manager.owns(value.properties.id), + (value) => { + if (value.type === "pty.exited") manager.exited(value.properties.id, value.properties.exitCode) + if (value.type === "pty.deleted") manager.deleted(value.properties.id) + }, + ) + const connection = input.connection.onStateChange((state) => { + if (state === "connected") void manager.sync() + }) + return { + manager, + dispose: async () => { + event() + connection() + await manager.dispose() + }, + } +} + +interface RunInput { + manager: ScriptTerminalManager + root(): string | undefined + state(): WorktreeStateManager | undefined + open(path: string): Promise + trusted(): boolean + post(message: AgentManagerOutMessage): void + log(message: string): void + refresh(): void +} + +export function createRunController(input: RunInput) { + return new RunController({ + root: input.root, + state: input.state, + open: input.open, + start: async (config, done) => { + if (!input.trusted()) throw new Error("Trust the workspace before running scripts") + return pickRunStart( + config.destination, + (cfg, cb) => input.manager.start("run", cfg, cb), + startVscodeRunTask, + )(config, done) + }, + post: (status) => input.post({ type: "agentManager.runStatus", ...status }), + error: (message) => input.post({ type: "error", message }), + log: input.log, + refresh: input.refresh, + }) +} diff --git a/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts b/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts new file mode 100644 index 00000000000..8151b460ee8 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts @@ -0,0 +1,17 @@ +export interface PtyServerConfig { + baseUrl: string + password: string +} + +/** Build the canonical authenticated PTY WebSocket URL for script terminals. */ +export function buildScriptTerminalWsUrl(config: PtyServerConfig, ptyID: string, cwd: string): string { + const base = config.baseUrl.replace(/^http/i, "ws").replace(/\/$/, "") + const token = Buffer.from(`kilo:${config.password}`).toString("base64") + const query = new URLSearchParams({ + "location[directory]": cwd, + cursor: "0", + replayExited: "1", + auth_token: token, + }) + return `${base}/api/pty/${encodeURIComponent(ptyID)}/connect?${query.toString()}` +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index d386e36acc8..3ca12f422ab 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -16,6 +16,7 @@ import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import" import type { RunStatus } from "./run/manager" import type { TerminalFont } from "./terminal-font" import type { TerminalDestination } from "./terminal-destination" +import type { ScriptTerminalView } from "./ScriptTerminalManager" export type { TerminalFont } @@ -177,6 +178,11 @@ interface TerminalFontChangedMessage { font: TerminalFont } +interface ScriptTerminalsMessage { + type: "agentManager.scriptTerminals" + terminals: ScriptTerminalView[] +} + interface ErrorOutMessage { type: "error" message: string @@ -332,6 +338,7 @@ export type AgentManagerOutMessage = | TerminalErrorMessage | TerminalDestinationChangedMessage | TerminalFontChangedMessage + | ScriptTerminalsMessage // --------------------------------------------------------------------------- // Webview → Extension messages (onMessage) @@ -398,6 +405,7 @@ interface ConfigureRunScriptIn { interface RunScriptIn { type: "agentManager.runScript" worktreeId: string + destination: TerminalDestination } interface StopRunScriptIn { diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index fc5dfd01078..4280c064a3f 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -173,6 +173,10 @@ export class VscodeHost implements Host { return getWorkspaceRoot() } + isTrusted(): boolean { + return vscode.workspace.isTrusted + } + autoBranchNaming(): { enabled: boolean; prefix: string } { const cfg = vscode.workspace.getConfiguration("kilo-code.new.agentManager") return { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index afcbae2f3d9..384b99a8889 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -60,6 +60,10 @@ const IMPORTER_FILE = path.join(ROOT, "src/agent-manager/worktree-importer.ts") const SETUP_SCRIPT_RUNNER_FILE = path.join(ROOT, "src/agent-manager/SetupScriptRunner.ts") const RUN_MESSAGE_FILE = path.join(ROOT, "src/agent-manager/run/message.ts") const TERMINAL_ROUTING_FILE = path.join(ROOT, "src/agent-manager/terminal-routing.ts") +const SCRIPT_TERMINAL_FILE = path.join(ROOT, "src/agent-manager/ScriptTerminalManager.ts") +const SCRIPT_TERMINAL_RUNTIME_FILE = path.join(ROOT, "src/agent-manager/script-terminal-runtime.ts") +const RUN_TASK_FILE = path.join(ROOT, "src/agent-manager/run/task.ts") +const RUN_DESTINATION_FILE = path.join(ROOT, "src/agent-manager/run/destination.ts") function readAllCss(): string { return CSS_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n") @@ -454,6 +458,52 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).not.toContain("agentManager.requestState") }) + it("routes script terminal close and resize messages before user terminals", () => { + const text = body("onMessage") + expect(text.indexOf("this.scripts.manager.intercept(m)")).toBeLessThan( + text.indexOf("this.terminalRouter.handle(m)"), + ) + }) + + it("runs scripts through the vscode-free canonical PTY manager", () => { + const text = fs.readFileSync(SCRIPT_TERMINAL_FILE, "utf-8") + expect(text).toMatch(/client\.v2\.pty\s*\.create/) + expect(text).toContain("client.v2.pty.get") + expect(text).toContain("client.v2.pty.update") + expect(text).toContain("client.v2.pty.remove") + expect(text).not.toContain("vscode") + }) + + it("selects the Run adapter from the panel dropdown message", () => { + const text = fs.readFileSync(SCRIPT_TERMINAL_RUNTIME_FILE, "utf-8") + expect(text).toContain("pickRunStart") + expect(text).toContain("config.destination") + expect(text).not.toContain("readRunTerminalDestination") + expect(text.indexOf("pickRunStart")).toBeLessThan(text.indexOf("config.destination")) + }) + + it("keeps the legacy integrated Run adapter isolated and removable", () => { + const task = fs.readFileSync(RUN_TASK_FILE, "utf-8") + expect(task).toContain("vscode.tasks.executeTask") + expect(task).toContain("Remove this") + const dest = fs.readFileSync(RUN_DESTINATION_FILE, "utf-8") + expect(dest).not.toContain('from "vscode"') + expect(dest).toContain("pickRunStart") + expect(dest).not.toContain("getConfiguration") + }) + + it("clears retained Run terminals before removing worktree state", () => { + for (const name of ["onDeleteWorktree", "onRemoveStaleWorktree"]) { + const text = body(name) + expect(text).toContain('this.scripts.manager.clear("run", worktreeId)') + expect(text.indexOf('this.scripts.manager.clear("run", worktreeId)')).toBeLessThan( + text.indexOf("state.removeWorktree"), + ) + } + const deleted = body("onDeleteWorktree") + expect(deleted.indexOf("statsPoller.skipWorktree")).toBeLessThan(deleted.indexOf("this.run.remove")) + }) + // -- onDeleteWorktree invariants ------------------------------------------- /** @@ -791,9 +841,6 @@ const VSCODE_ALLOWED: Record = { "task-runner.ts": { note: "vscode adapter for SetupScriptRunner", }, - "run/task.ts": { - note: "vscode adapter for Agent Manager run scripts", - }, // Reads terminal.integrated.* and editor.font* config for xterm font settings "terminal-font.ts": { note: "vscode config reader for integrated terminal font settings", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts new file mode 100644 index 00000000000..eff96950cd9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import { terminalChrome } from "../../webview-ui/agent-manager/terminal/chrome" + +describe("Agent Manager Run terminal chrome", () => { + it("keeps the console icon for user terminals", () => { + expect(terminalChrome("Terminal 1", undefined)).toEqual({ icon: "console", tooltip: "Terminal 1" }) + }) + + it("renders compact status icons with accessible Run status details", () => { + expect(terminalChrome("Run", { state: "running" })).toEqual({ icon: "spinner", tooltip: "Run (Running)" }) + expect(terminalChrome("Run", { state: "stopping" })).toEqual({ icon: "spinner", tooltip: "Run (Stopping)" }) + expect(terminalChrome("Run", { state: "exited", exitCode: 0 })).toEqual({ + icon: "success", + tooltip: "Run (Exited, code 0)", + }) + expect(terminalChrome("Run", { state: "exited", exitCode: 1 })).toEqual({ + icon: "failure", + tooltip: "Run (Exited, code 1)", + }) + expect(terminalChrome("Run", { state: "failed" })).toEqual({ icon: "failure", tooltip: "Run (Failed)" }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts index 9a071e5c150..3043b1c9065 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test" import { createSideTerminal, readSavedDestination, + resolveRunScriptRequest, resolveVscodeTerminalRequest, } from "../../webview-ui/agent-manager/terminal/side" @@ -147,6 +148,21 @@ describe("readSavedDestination", () => { }) }) +describe("resolveRunScriptRequest", () => { + it("carries the current panel dropdown destination with every Run request", () => { + expect(resolveRunScriptRequest("wt-1", "agentManager")).toEqual({ + type: "agentManager.runScript", + worktreeId: "wt-1", + destination: "agentManager", + }) + expect(resolveRunScriptRequest("local", "vscode")).toEqual({ + type: "agentManager.runScript", + worktreeId: "local", + destination: "vscode", + }) + }) +}) + describe("resolveVscodeTerminalRequest", () => { const sessions = new Map([ ["wt-1", "session-a"], diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts index db0acb769f5..d533f33895d 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -5,6 +5,7 @@ import { createTerminalHandlers, createTerminalMessageHandler, createTerminalState, + isTerminalTabId, } from "../../webview-ui/agent-manager/terminal/state" import type { ExtensionMessage } from "../../webview-ui/src/types/messages/extension-messages" @@ -14,7 +15,14 @@ function scene(initial: string | null = LOCAL) { const [selection, setSelection] = createSignal(initial) const state = createTerminalState(selection) const posted: Array> = [] - const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], errors: 0 } + const events = { + activated: [] as string[], + selected: [] as string[], + saved: 0, + shown: [] as string[], + errors: 0, + running: [] as Array<{ contextKey: string; terminalId: string }>, + } const tabs = () => state.current().map((term) => term.id) const handlers = createTerminalHandlers({ state, @@ -41,6 +49,7 @@ function scene(initial: string | null = LOCAL) { }, showError: () => events.errors++, postMessage: (message) => posted.push(message as Record), + onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }), }) return { state, selection, setSelection, posted, events, handlers, dispatch } } @@ -58,6 +67,28 @@ function createdSide(createId: string, terminalId: string, title = "Terminal 1") } satisfies ExtensionMessage } +function script( + terminalId: string, + state: "running" | "stopping" | "exited" | "failed" = "running", + exitCode?: number, +) { + return { + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId, + worktreeId: null, + kind: "run", + title: "Run", + wsUrl: `ws://${terminalId}`, + state, + ...(exitCode === undefined ? {} : { exitCode }), + font, + }, + ], + } satisfies ExtensionMessage +} + describe("Agent Manager terminal state", () => { it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => { createRoot((dispose) => { @@ -91,6 +122,47 @@ describe("Agent Manager terminal state", () => { }) }) + it("hydrates complete Run snapshots without create ids and preserves mounted terminal records", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" }) + const user = item.state.sidesForContext(LOCAL)[0]! + + expect(item.dispatch(script("script:run"))).toBe(true) + const run = item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run") + expect(run).toMatchObject({ title: "Run", placement: "side", kind: "run", contextKey: LOCAL }) + expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }]) + expect(item.state.scriptStatus("script:run")).toEqual({ state: "running" }) + expect(isTerminalTabId("script:run")).toBe(true) + + item.state.setTitle("script:run", "npm test") + expect(item.state.title("script:run")).toBe("Run") + + item.dispatch(script("script:run", "exited", 0)) + expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run")).toBe(run) + expect(item.state.scriptStatus("script:run")).toEqual({ state: "exited", exitCode: 0 }) + expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "terminal:user")).toBe(user) + // Existing snapshots update status only; they do not re-open the inspector. + expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }]) + + item.dispatch({ type: "agentManager.scriptTerminals", terminals: [] } satisfies ExtensionMessage) + expect(item.state.sidesForContext(LOCAL)).toEqual([user]) + expect(item.state.scriptStatus("script:run")).toBeUndefined() + dispose() + }) + }) + + it("maps Local Run snapshots to LOCAL and does not reveal exited terminals", () => { + createRoot((dispose) => { + const item = scene() + item.dispatch(script("script:exit", "exited", 2)) + + expect(item.state.sidesForContext(LOCAL)[0]).toMatchObject({ id: "script:exit", contextKey: LOCAL }) + expect(item.events.running).toEqual([]) + dispose() + }) + }) + it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => { createRoot((dispose) => { const item = scene() @@ -170,6 +242,26 @@ describe("Agent Manager terminal state", () => { }) }) + it("waits for Run closure confirmation while user terminal closes stay optimistic", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" }) + item.dispatch(script("script:run")) + + expect(item.handlers.closeSide("script:run")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:user", "script:run"]) + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "script:run" }]) + + expect(item.handlers.closeSide("terminal:user")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["script:run"]) + expect(item.posted).toEqual([ + { type: "agentManager.terminal.close", terminalId: "script:run" }, + { type: "agentManager.terminal.close", terminalId: "terminal:user" }, + ]) + dispose() + }) + }) + it("closes a stale side answer whose create request is unknown", () => { createRoot((dispose) => { const item = scene() diff --git a/packages/kilo-vscode/tests/unit/run-message.test.ts b/packages/kilo-vscode/tests/unit/run-message.test.ts new file mode 100644 index 00000000000..2f122026dab --- /dev/null +++ b/packages/kilo-vscode/tests/unit/run-message.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, mock } from "bun:test" +import type { RunController } from "../../src/agent-manager/run/controller" +import { handleRunMessage } from "../../src/agent-manager/run/message" +import type { AgentManagerInMessage } from "../../src/agent-manager/types" + +function controller() { + const run = mock(() => Promise.resolve()) + const stop = mock(() => undefined) + const configure = mock(() => Promise.resolve()) + return { + value: { run, stop, configure } as unknown as RunController, + run, + stop, + configure, + } +} + +describe("Agent Manager Run messages", () => { + it.each(["agentManager", "vscode"] as const)("forwards the %s dropdown destination", (destination) => { + const item = controller() + const msg = { + type: "agentManager.runScript", + worktreeId: "wt-1", + destination, + } satisfies AgentManagerInMessage + + expect(handleRunMessage(item.value, msg)).toBe(true) + expect(item.run).toHaveBeenCalledWith("wt-1", destination) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/run-script-manager.test.ts b/packages/kilo-vscode/tests/unit/run-script-manager.test.ts index baaab57e3f6..59bd3c2df81 100644 --- a/packages/kilo-vscode/tests/unit/run-script-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/run-script-manager.test.ts @@ -86,7 +86,7 @@ describe("RunScriptManager", () => { let stopped = 0 await ctx.manager.start("wt-1", async () => ({ stop: () => stopped++ })) - ctx.manager.remove("wt-1") + await ctx.manager.remove("wt-1") expect(stopped).toBe(1) expect(ctx.manager.all()).toEqual([]) @@ -123,12 +123,28 @@ describe("RunScriptManager", () => { it("finish after remove does not resurrect stale state", async () => { const ctx = createManager() await ctx.manager.start("wt-1", async () => ({ stop: () => {} })) - ctx.manager.remove("wt-1") + await ctx.manager.remove("wt-1") ctx.manager.finish("wt-1", { exitCode: 0 }) expect(ctx.manager.all()).toEqual([]) }) + it("stops and disposes once when removal races startup", async () => { + const ctx = createManager() + const gate = deferred() + let stopped = 0 + let disposed = 0 + const started = ctx.manager.start("wt-1", () => gate.promise) + const removed = ctx.manager.remove("wt-1") + + gate.resolve({ stop: () => stopped++, dispose: () => disposed++ }) + await Promise.all([started, removed]) + + expect(stopped).toBe(1) + expect(disposed).toBe(1) + expect(ctx.manager.all()).toEqual([]) + }) + it("dispose tolerates handles that throw on stop", async () => { const ctx = createManager() await ctx.manager.start("wt-1", async () => ({ diff --git a/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts b/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts new file mode 100644 index 00000000000..d2a16dee95f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test" +import type { StartTask } from "../../src/agent-manager/run/controller" +import { pickRunStart } from "../../src/agent-manager/run/destination" + +describe("Run terminal destination", () => { + it("picks the adapter matching the panel dropdown destination", () => { + const handle = { stop: () => undefined, dispose: () => undefined } + const embedded: StartTask = async () => handle + const integrated: StartTask = async () => handle + + expect(pickRunStart("agentManager", embedded, integrated)).toBe(embedded) + expect(pickRunStart("vscode", embedded, integrated)).toBe(integrated) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts new file mode 100644 index 00000000000..01bea86435a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { ScriptTerminalManager, type ScriptTerminalView } from "../../src/agent-manager/ScriptTerminalManager" +import { buildScriptTerminalWsUrl } from "../../src/agent-manager/script-terminal-url" +import { RunScriptManager, type RunStatus } from "../../src/agent-manager/run/manager" + +interface PtyInput { + location?: { directory?: string } + command?: string + args?: string[] + cwd?: string + env?: Record + title?: string +} + +interface PtyUpdate { + ptyID: string + location?: { directory?: string } + size?: { cols: number; rows: number } +} + +interface PtyInfo { + id: string + title: string + command: string + args: string[] + cwd: string + status: "running" | "exited" + pid: number + exitCode?: number +} + +interface PtyResponse { + data?: { location: { directory: string }; data: PtyInfo } + error?: unknown +} + +function wait(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function info(status: PtyInfo["status"] = "running", exitCode?: number): PtyInfo { + return { + id: "pty-1", + title: "Run", + command: "bun", + args: ["run", "check"], + cwd: "/repo/worktree", + status, + pid: 42, + ...(exitCode === undefined ? {} : { exitCode }), + } +} + +function harness(opts?: { + create?: (input: PtyInput) => Promise + get?: () => Promise + remove?: () => Promise<{ data?: unknown; error?: unknown }> +}) { + const calls: { create: PtyInput[]; get: unknown[]; update: PtyUpdate[]; remove: unknown[] } = { + create: [], + get: [], + update: [], + remove: [], + } + const snapshots: ScriptTerminalView[][] = [] + const closed: string[] = [] + const logs: string[] = [] + const client = { + v2: { + pty: { + create: async (input: PtyInput) => { + calls.create.push(input) + return opts?.create ? opts.create(input) : { data: { location: { directory: config.cwd }, data: info() } } + }, + get: async (input: unknown) => { + calls.get.push(input) + return opts?.get ? opts.get() : { data: { location: { directory: config.cwd }, data: info() } } + }, + update: async (input: PtyUpdate) => { + calls.update.push(input) + return { data: info() } + }, + remove: async (input: unknown) => { + calls.remove.push(input) + return opts?.remove ? opts.remove() : { data: undefined } + }, + }, + }, + } as unknown as KiloClient + const manager = new ScriptTerminalManager({ + getClient: () => client, + getClientAsync: async () => client, + buildWsUrl: (ptyID, cwd) => `ws://127.0.0.1:4096/api/pty/${ptyID}/connect?location=${cwd}`, + getTerminalFont: () => ({ fontFamily: "Menlo", fontSize: 12 }), + emit: (terminals) => snapshots.push(terminals), + closed: (terminalId) => closed.push(terminalId), + log: (msg) => logs.push(msg), + }) + return { manager, calls, snapshots, closed, logs } +} + +const config = { + worktreeId: "wt-1", + command: "bun", + args: ["run", "check"], + cwd: "/repo/worktree", + env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" }, +} + +describe("ScriptTerminalManager", () => { + it("creates a Run PTY with explicit command settings and a safe snapshot", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + + expect(ctx.calls.create).toEqual([ + { + location: { directory: "/repo/worktree" }, + command: "bun", + args: ["run", "check"], + cwd: "/repo/worktree", + env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" }, + title: "Run", + }, + ]) + expect(ctx.calls.get).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(ctx.snapshots.at(-1)).toEqual([ + expect.objectContaining({ + worktreeId: "wt-1", + kind: "run", + title: "Run", + state: "running", + font: { fontFamily: "Menlo", fontSize: 12 }, + }), + ]) + expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"command"') + expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"env"') + expect(done).toEqual([]) + }) + + it("normalizes the internal local Run key to a null external worktree id", async () => { + const ctx = harness() + + await ctx.manager.start("run", { ...config, worktreeId: "local", cwd: "/repo" }, () => undefined) + + expect(ctx.snapshots.at(-1)?.[0]?.worktreeId).toBeNull() + }) + + it("builds canonical authenticated replay URLs", () => { + const value = buildScriptTerminalWsUrl( + { baseUrl: "http://127.0.0.1:4096", password: "secret" }, + "pty / 1", + "/repo/worktree", + ) + const url = new URL(value) + + expect(url.protocol).toBe("ws:") + expect(url.pathname).toBe("/api/pty/pty%20%2F%201/connect") + expect(url.searchParams.get("location[directory]")).toBe("/repo/worktree") + expect(url.searchParams.get("cursor")).toBe("0") + expect(url.searchParams.get("replayExited")).toBe("1") + expect(url.searchParams.get("auth_token")).toBe(Buffer.from("kilo:secret").toString("base64")) + }) + + it("finishes once on a natural exit and retains the replayable terminal", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + ctx.manager.exited("pty-1", 17) + ctx.manager.exited("pty-1", 17) + + expect(done).toEqual([{ exitCode: 17 }]) + expect(ctx.calls.remove).toEqual([]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "exited", exitCode: 17 })]) + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("reconciles a PTY that exited before registration", async () => { + const ctx = harness({ + get: async () => ({ data: { location: { directory: config.cwd }, data: info("exited", 7) } }), + }) + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + + expect(done).toEqual([{ exitCode: 7 }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 7 })]) + }) + + it("reconciles an exit event that arrives before create registration", async () => { + const gate = deferred() + let state = info() + const ctx = harness({ + create: async () => gate.promise, + get: async () => ({ data: { location: { directory: config.cwd }, data: state } }), + }) + const done: unknown[] = [] + const started = ctx.manager.start("run", config, (exit) => done.push(exit)) + + await wait() + state = info("exited", 9) + ctx.manager.exited("pty-1", 9) + gate.resolve({ data: { location: { directory: config.cwd }, data: info() } }) + await started + + expect(done).toEqual([{ exitCode: 9 }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 9 })]) + }) + + it("treats an already removed backend PTY as a successful close", async () => { + const ctx = harness({ remove: async () => ({ error: { _tag: "PtyNotFoundError", status: 404 } }) }) + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + + expect(await ctx.manager.close(terminalId)).toBe(true) + expect(done).toEqual([{ stopped: true }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("stops a PTY when stop races startup", async () => { + const gate = deferred() + const ctx = harness({ create: async () => gate.promise }) + const statuses: RunStatus[] = [] + const run = new RunScriptManager( + () => undefined, + (status) => statuses.push({ ...status }), + () => new Date("2026-01-02T03:04:05.000Z"), + ) + const started = run.start("wt-1", () => ctx.manager.start("run", config, (exit) => run.finish("wt-1", exit))) + + await wait() + await run.stop("wt-1") + gate.resolve({ data: { location: { directory: config.cwd }, data: info() } }) + await started + await wait() + + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(statuses.map((status) => status.state)).toEqual(["running", "stopping", "idle"]) + expect(run.status("wt-1")).toMatchObject({ state: "idle", stopped: true }) + }) + + it("intercepts resize and stops a running terminal when it closes", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + expect(ctx.manager.intercept({ type: "agentManager.terminal.resize", terminalId, cols: 120, rows: 40 })).toBe(true) + await wait() + expect(ctx.calls.update).toEqual([ + { ptyID: "pty-1", location: { directory: "/repo/worktree" }, size: { cols: 120, rows: 40 } }, + ]) + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(done).toEqual([{ stopped: true }]) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("retries closure after a Run terminal removal fails", async () => { + let attempt = 0 + const ctx = harness({ + remove: async () => { + attempt++ + if (attempt === 1) return { error: new Error("still running") } + return { data: undefined } + }, + }) + + await ctx.manager.start("run", config, () => undefined) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + + expect(ctx.closed).toEqual([]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })]) + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + + expect(ctx.calls.remove).toHaveLength(2) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("drops a retained Run terminal when the backend evicts it", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + ctx.manager.exited("pty-1", 0) + ctx.manager.deleted("pty-1") + + expect(done).toEqual([{ exitCode: 0 }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + expect(ctx.calls.remove).toEqual([]) + }) + + it("reconciles a natural exit missed during an event-stream reconnect", async () => { + let state: PtyInfo = info() + const ctx = harness({ get: async () => ({ data: { location: { directory: config.cwd }, data: state } }) }) + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + state = info("exited", 23) + await ctx.manager.sync() + + expect(done).toEqual([{ exitCode: 23 }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 23 })]) + }) + + it("clears retained exited terminals by worktree context", async () => { + const ctx = harness() + + await ctx.manager.start("run", config, () => undefined) + ctx.manager.exited("pty-1", 0) + + expect(await ctx.manager.clear("run", "wt-1")).toBe(true) + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("replays the full retained snapshot after a webview reload", async () => { + const ctx = harness() + + await ctx.manager.start("run", config, () => undefined) + const first = ctx.snapshots.at(-1) + ctx.manager.snapshot() + + expect(ctx.snapshots.at(-1)).toEqual(first) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 7f1773ba895..d4040360d4f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -38,6 +38,7 @@ import type { SessionInfo, SessionCreatedMessage, BranchInfo, + TerminalDestination, } from "../src/types/messages" import { IndexingProvider } from "../src/context/indexing" import { @@ -125,6 +126,7 @@ import { createTerminalMessageHandler, createSideTerminal, readSavedDestination, + resolveRunScriptRequest, resolveVscodeTerminalRequest, } from "./terminal" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" @@ -418,20 +420,20 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel }) } - const runWorktree = (id: string) => { + const runWorktree = (id: string, destination: TerminalDestination) => { const state = runStatuses()[id]?.state ?? "idle" if (state === "running" || state === "stopping") { vscode.postMessage({ type: "agentManager.stopRunScript", worktreeId: id }) return } - vscode.postMessage({ type: "agentManager.runScript", worktreeId: id }) + vscode.postMessage(resolveRunScriptRequest(id, destination)) } const configureRunScript = () => vscode.postMessage({ type: "agentManager.configureRunScript" }) const runSelected = () => { const sel = selection() - if (sel) runWorktree(sel) + if (sel) runWorktree(sel, sideCtl.destination()) } const isPending = (id: string) => id.startsWith(PENDING_PREFIX) @@ -1108,6 +1110,12 @@ const AgentManagerContent: Component = () => { // a slow create landing after a mode switch must not steal it. if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId) }, + onScriptRunning: (contextKey, terminalId) => { + if (terms.sideKey() !== contextKey) return + showSideTerminal() + terms.setSideActive(contextKey, terminalId) + terms.requestFocus(terminalId) + }, onDestinationChanged: (destination) => sideCtl.syncDefault(destination), }) const unsubTerminals = vscode.onMessage((msg) => { @@ -2530,7 +2538,7 @@ const AgentManagerContent: Component = () => { onClick={metrics.click( "run_script", "tab_toolbar", - () => runWorktree(rid()), + () => runWorktree(rid(), sideCtl.destination()), () => ({ action: active() ? "stop" : configured() ? "run" : "configure", }), diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 0cf28616bf2..fb1da83463c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1296,6 +1296,19 @@ button.am-section-toggle:hover .am-section-label { color: currentColor; } +.am-tab-icon[data-run-status="success"] { + color: var(--vscode-testing-iconPassed, #34d399); +} + +.am-tab-icon[data-run-status="failure"] { + color: var(--vscode-testing-iconFailed, #f87171); +} + +.am-terminal-tab-spinner { + width: 12px; + height: 12px; +} + .am-tab-label { flex: 1; min-width: 0; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx index 517965d88d3..bdce9bd3f6c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -104,6 +104,7 @@ export const SideTerminalPanel: Component = (props) => { void }> = (props) => { const { t } = useLanguage() + const chrome = () => terminalChrome(props.tooltip, props.status) + const icon = () => { + const kind = chrome().icon + if (kind === "success") return "check-small" + if (kind === "failure") return "warning" + return "console" + } return (
- - + + }> + + {props.label} @@ -86,6 +100,7 @@ export const SortableTerminalTab: Component<{ id: string label: string tooltip: string + status?: ScriptTerminalStatus keybind?: string closeKeybind?: string active: boolean @@ -106,6 +121,7 @@ export const SortableTerminalTab: Component<{ id.startsWith(TERMINAL_PREFIX) +export const isTerminalTabId = (id: string): boolean => + id.startsWith(TERMINAL_PREFIX) || id.startsWith(SCRIPT_TERMINAL_PREFIX) + +/** Status is separate from mounted xterm records so snapshot updates never remount them. */ +export type ScriptTerminalStatus = Pick /** One row in `terminalsByContext`. `wsUrl` is short-lived and never persisted. */ export interface TerminalTabState { @@ -32,6 +37,8 @@ export interface TerminalTabState { wsUrl: string font: TerminalFont placement: TerminalPlacement + /** Provider-owned Run terminal, never created through the webview create flow. */ + kind?: "run" } /** Terminal row enriched with the sidebar context it belongs to. Used by @@ -63,6 +70,12 @@ export interface TerminalStateControls { remove(terminalId: string): TerminalTabStateWithContext | undefined /** Resolve the context key a terminal lives in, if any. */ contextFor(terminalId: string): string | undefined + /** Whether a terminal belongs to a provider-owned Run script. */ + isScript(terminalId: string): boolean + /** Reactive Run state, kept apart from stable xterm terminal records. */ + scriptStatus(terminalId: string): ScriptTerminalStatus | undefined + /** Reconcile a complete provider-owned Run terminal snapshot. Returns newly hydrated records. */ + syncScripts(views: ScriptTerminalView[]): TerminalTabStateWithContext[] /** All tab terminals for the given sidebar selection. */ forSelection(selection: string | null): TerminalTabStateWithContext[] /** Map of { id -> tab state } for O(1) lookup. */ @@ -166,6 +179,7 @@ export function createTerminalState(selection: Accessor): Termina // records on purpose: replacing a record would remount its xterm via // reference inequality (see the module comment above). const [titles, setTitles] = createSignal>({}) + const [scripts, setScripts] = createSignal>({}) // Active side terminal per context. const [actives, setActives] = createSignal>({}) let focusSerial = 0 @@ -228,14 +242,18 @@ export function createTerminalState(selection: Accessor): Termina } const title = (terminalId: string): string | undefined => { - const live = titles()[terminalId] - if (live) return live const key = contextFor(terminalId) if (!key) return undefined - return terminalsByContext()[key]?.find((t) => t.id === terminalId)?.title + const term = terminalsByContext()[key]?.find((t) => t.id === terminalId) + if (!term) return undefined + // Run terminals always retain their semantic title, even when their + // command emits OSC title sequences. + if (term.kind === "run") return term.title + return titles()[terminalId] ?? term.title } const setTitle = (terminalId: string, next: string) => { + if (isScript(terminalId)) return const trimmed = next.trim() if (!trimmed) return setTitles((prev) => (prev[terminalId] === trimmed ? prev : { ...prev, [terminalId]: trimmed })) @@ -250,6 +268,16 @@ export function createTerminalState(selection: Accessor): Termina return undefined } + const isScript = (terminalId: string): boolean => { + const key = contextFor(terminalId) + return terminalsByContext()[key ?? ""]?.some((term) => term.id === terminalId && term.kind === "run") ?? false + } + + const scriptStatus = (terminalId: string): ScriptTerminalStatus | undefined => { + if (!isScript(terminalId)) return undefined + return scripts()[terminalId] + } + const forSelection = (sel: string | null): TerminalTabStateWithContext[] => { if (sel === null) return [] const key = sel === LOCAL ? LOCAL : sel @@ -296,9 +324,95 @@ export function createTerminalState(selection: Accessor): Termina return next }) } + if (removed?.kind === "run" && scripts()[terminalId] !== undefined) { + setScripts((prev) => { + const next = { ...prev } + delete next[terminalId] + return next + }) + } return removed } + const syncScripts = (views: ScriptTerminalView[]): TerminalTabStateWithContext[] => { + const ids = new Set(views.map((view) => view.terminalId)) + const added: TerminalTabStateWithContext[] = [] + const removed: TerminalTabStateWithContext[] = [] + + setTerminalsByContext((prev) => { + let changed = false + const next: Record = {} + for (const [key, list] of Object.entries(prev)) { + const kept = list.filter((term) => { + if (term.kind !== "run" || ids.has(term.id)) return true + removed.push(term) + changed = true + return false + }) + if (kept.length > 0) next[key] = kept + } + for (const view of views) { + const key = view.worktreeId ?? LOCAL + const list = next[key] ?? [] + if (list.some((term) => term.id === view.terminalId)) continue + const term: TerminalTabStateWithContext = { + id: view.terminalId, + title: "Run", + wsUrl: view.wsUrl, + font: view.font, + placement: "side", + kind: "run", + contextKey: key, + } + next[key] = [...list, term] + added.push(term) + changed = true + } + return changed ? next : prev + }) + + const states: Record = {} + for (const view of views) { + const status: ScriptTerminalStatus = { state: view.state } + if (view.exitCode !== undefined) status.exitCode = view.exitCode + states[view.terminalId] = status + } + setScripts((prev) => { + const keys = Object.keys(states) + if (keys.length !== Object.keys(prev).length) return states + for (const id of keys) { + const before = prev[id] + const after = states[id] + if (before?.state !== after?.state || before?.exitCode !== after?.exitCode) return states + } + return prev + }) + + if (removed.length > 0) { + const removedIds = new Set(removed.map((term) => term.id)) + if (focusedId() && removedIds.has(focusedId()!)) setFocusedId(undefined) + if (activeId() && removedIds.has(activeId()!)) setActiveId(undefined) + setTitles((prev) => { + const next = { ...prev } + for (const id of removedIds) delete next[id] + return next + }) + setActives((prev) => { + let changed = false + const next = { ...prev } + for (const key of new Set(removed.map((term) => term.contextKey))) { + if (!prev[key] || !removedIds.has(prev[key]!)) continue + const rest = sidesForContext(key) + if (rest.length === 0) delete next[key] + else next[key] = rest[rest.length - 1]!.id + changed = true + } + return changed ? next : prev + }) + } + return added + } + const requestFocus = (id: string) => { focusSerial++ setFocusRequest({ id, serial: focusSerial }) @@ -418,6 +532,9 @@ export function createTerminalState(selection: Accessor): Termina add, remove, contextFor, + isScript, + scriptStatus, + syncScripts, forSelection, lookup, current, @@ -539,6 +656,13 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { } const closeTerminal = (terminalId: string) => { + // Run terminals transition through a provider-owned stopping snapshot. + // Keep their xterm mounted until closure is confirmed by a snapshot or + // terminal.closed message so live output is never discarded early. + if (deps.state.isScript(terminalId)) { + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) + return + } deps.onRemove?.() const ids = deps.tabIds() const idx = ids.indexOf(terminalId) @@ -582,6 +706,10 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { // unmount its xterm while the backend PTY leaks (no close sent). const term = deps.state.sides().find((t) => t.id === terminalId) if (!term) return false + if (term.kind === "run") { + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) + return true + } deps.state.remove(terminalId) deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true @@ -644,11 +772,14 @@ export interface TerminalMessageHandlerDeps { onSideError?: (contextKey: string) => void /** Side terminal was closed (locally or by the extension). */ onSideClosed?: (contextKey: string) => void + /** A newly hydrated running Run terminal belongs to the selected context. */ + onScriptRunning?: (contextKey: string, terminalId: string) => void /** The destination setting changed (live settings sync). */ onDestinationChanged?: (destination: TerminalDestination) => void } type CreatedMessage = Extract +type ScriptTerminalsMessage = Extract function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId @@ -682,6 +813,13 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { deps.activate(msg.terminalId) } +function handleScriptTerminals(deps: TerminalMessageHandlerDeps, msg: ScriptTerminalsMessage) { + const added = deps.state.syncScripts(msg.terminals) + for (const term of added) { + if (deps.state.scriptStatus(term.id)?.state === "running") deps.onScriptRunning?.(term.contextKey, term.id) + } +} + /** * Wire handlers for the inbound terminal messages. Returns a dispatcher * that accepts each message type and returns true if it handled the @@ -694,6 +832,10 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { handleCreated(deps, msg) return true } + if (msg.type === "agentManager.scriptTerminals") { + handleScriptTerminals(deps, msg) + return true + } if (msg.type === "agentManager.terminal.closed") { const removed = deps.state.remove(msg.terminalId) if (deps.state.activeId() === msg.terminalId) deps.state.setActiveId(undefined) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 30a55b4de18..47834b34285 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -733,6 +733,24 @@ export interface AgentManagerTerminalDestinationChangedMessage { destination: TerminalDestination } +/** Provider-owned Run script terminal. Full snapshots replace only this terminal kind. */ +export interface ScriptTerminalView { + terminalId: string + /** null for LOCAL, worktree id otherwise */ + worktreeId: string | null + kind: "run" + title: "Run" + wsUrl: string + state: "running" | "stopping" | "exited" | "failed" + exitCode?: number + font: TerminalFont +} + +export interface AgentManagerScriptTerminalsMessage { + type: "agentManager.scriptTerminals" + terminals: ScriptTerminalView[] +} + export interface AgentManagerRunStatusMessage extends RunStatus { type: "agentManager.runStatus" } @@ -1245,6 +1263,7 @@ export type ExtensionMessage = | AgentManagerTerminalClosedMessage | AgentManagerTerminalErrorMessage | AgentManagerTerminalDestinationChangedMessage + | AgentManagerScriptTerminalsMessage // legacy-migration start | MigrationStateMessage | MigrationDataMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index f5dc4a2035c..1851f6fe690 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -4,7 +4,7 @@ import type { MessageLoadMode } from "./sessions" import type { PermissionFileDiff } from "./permissions" import type { ModelSelection, ProviderConfig } from "./providers" import type { Config } from "./config" -import type { ModelAllocation, ReviewComment, TerminalPlacement } from "./agent-manager" +import type { ModelAllocation, ReviewComment, TerminalDestination, TerminalPlacement } from "./agent-manager" import type { ReviewMessageData } from "../../../../src/shared/review-comments" import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets" import type { AnacondaDesktopWebviewMessage } from "../../../../src/shared/anaconda-desktop-messages" @@ -677,6 +677,7 @@ export interface ConfigureRunScriptRequest { export interface RunScriptRequest { type: "agentManager.runScript" worktreeId: string + destination: TerminalDestination } export interface StopRunScriptRequest { diff --git a/packages/server/src/groups/pty.ts b/packages/server/src/groups/pty.ts index 1c07a3e32f6..3e6cb4631d2 100644 --- a/packages/server/src/groups/pty.ts +++ b/packages/server/src/groups/pty.ts @@ -10,6 +10,7 @@ import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./locat export const PTY_CONNECT_TICKET_QUERY = "ticket" export const PTY_CONNECT_TOKEN_HEADER = "x-kilo-ticket" export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" +export const PTY_REPLAY_EXITED_QUERY = "replayExited" const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/ @@ -130,7 +131,13 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ...operation, parameters: [ ...(operation.parameters ?? []), - ...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({ + ...[ + "location[directory]", + "location[workspace]", + "cursor", + PTY_CONNECT_TICKET_QUERY, + PTY_REPLAY_EXITED_QUERY, + ].map((name) => ({ in: "query", name, schema: { type: "string" }, diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index a59afb3b316..bcb675e12d3 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -9,7 +9,12 @@ import * as Socket from "effect/unstable/socket/Socket" import { Api } from "../api" import { CorsConfig, isAllowedRequestOrigin } from "../cors" import { ForbiddenError, PtyNotFoundError } from "../errors" -import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "../groups/pty" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, + PTY_REPLAY_EXITED_QUERY, +} from "../groups/pty" import { response } from "../groups/location" import { PtyEnvironment } from "../pty-environment" @@ -178,6 +183,7 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) => cursor, onData: (chunk) => Queue.offerUnsafe(outbox, chunk), onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)), + allowExited: url.searchParams.get(PTY_REPLAY_EXITED_QUERY) === "1", // kilocode_change }) .pipe( Effect.catchTags({