feat(agent-manager): run project scripts in the embedded side terminal

This commit is contained in:
marius-kilocode
2026-07-30 10:16:51 +02:00
parent a0364858a6
commit 49223b3ade
31 changed files with 2171 additions and 130 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Run Agent Manager project scripts in a named side terminal without opening the bottom VS Code terminal panel.
@@ -0,0 +1,575 @@
# Agent Manager Script Terminals
Status: implementation plan only
Baseline researched: `main` at `a0364858a6e1b69a2e2dc5434a82d5cefbe79ea7` (`v7.4.17`)
Related issues:
- [#12595](https://github.com/Kilo-Org/kilocode/issues/12595), per-worktree session terminals in the embedded side panel
- [#11083](https://github.com/Kilo-Org/kilocode/issues/11083), setup blocks session start and disrupts the terminal layout
- [#7526](https://github.com/Kilo-Org/kilocode/issues/7526), original Agent Manager Run script feature
- [#12597](https://github.com/Kilo-Org/kilocode/issues/12597), multiple side terminals, already implemented
- [#12649](https://github.com/Kilo-Org/kilocode/issues/12649), setup-script migration split from this implementation
## Goal
Setup scripts and Run scripts must execute in first-class terminal tabs inside the Agent Manager right-side terminal panel. Neither action should reveal or require the bottom VS Code terminal panel.
The result must:
- run the existing platform-specific setup and Run script files on Windows, Linux, and macOS;
- preserve the current working directory, environment, exit status, timeout, and one-Run-per-context behavior;
- show live output, accept input for interactive scripts, and retain bounded scrollback after exit;
- stop the correct process and its descendants;
- survive Agent Manager webview reloads and context switching while the extension/backend remain alive;
- reject unsafe script paths and respect VS Code Workspace Trust;
- never construct a shell command string in the webview or inject a command through terminal input.
## Issue Scope Correction
Issue #12595 currently describes routing a plain session/worktree shell through the existing `terminalButtonDestination` preference. Implementing that issue literally does not migrate either script system:
- setup scripts still use `vscode.tasks.executeTask()` through `task-runner.ts`;
- Run scripts still use `vscode.tasks.executeTask()` through `run/task.ts`;
- both task definitions use `TaskRevealKind.Always`, which opens the bottom terminal panel.
Before implementation, revise #12595 or replace its acceptance criteria with this plan. The plain session-terminal routing can remain a small related change, but it is not sufficient for the stated product goal.
This plan addresses only the terminal/output part of #11083. Setup remains awaited before the first worktree session starts in the initial implementation. Making setup asynchronous is a separate lifecycle change and should not be combined with the terminal migration.
## Product Decisions
### Script output always belongs to Agent Manager
Setup and Run are Agent Manager operations, so they always use named side-panel terminals:
- `Setup` for worktree setup
- `Run` for the selected Local or worktree context
They do not follow `kilo-code.new.agentManager.terminalButtonDestination`. That setting continues to control only where an ordinary user-requested interactive shell opens. This keeps the meaning of the setting narrow and avoids adding another preference.
### No automatic VS Code terminal fallback
If script PTY creation fails, show an Agent Manager error and do not execute the script. Automatically falling back after an uncertain PTY failure could execute a setup or Run script twice.
An explicit manual action may open an ordinary VS Code terminal, but it must not silently rerun the script.
### Preserve current lifecycle semantics
- Setup stays best-effort: nonzero exit is visible as a failure, but session creation continues as it does today.
- Setup keeps its five-minute timeout, but the timeout must now terminate the process tree instead of only rejecting the wait.
- Run remains a toggle: invoking Run while active requests Stop rather than starting a second process.
- One Run process exists per Local/worktree context.
- Run status remains in memory and continues to drive the existing Run/Stop button and worktree status badge.
## Current Architecture
### Setup
`SetupScriptRunner` is already platform-neutral and receives an injected `RunTask` callback. It resolves:
| Platform | Script | Executable and arguments |
|---|---|---|
| Linux/macOS | `.kilo/setup-script`, then `.sh` | `sh <absolute-script-path>` |
| Windows | `.ps1`, then `.cmd`, then `.bat` | `powershell.exe ... -File <path>` or `cmd.exe /d /s /c <quoted-path>` |
The VS Code-specific adapter is `packages/kilo-vscode/src/agent-manager/task-runner.ts`.
### Run
`RunController` and `RunScriptManager` already separate discovery, lifecycle, and UI status from the execution adapter. `RunController` passes an explicit executable, argument array, cwd, environment, and completion callback to `startVscodeRunTask()`.
The VS Code-specific adapter is `packages/kilo-vscode/src/agent-manager/run/task.ts`.
### Embedded terminals
Agent Manager already has:
- xterm.js terminals in the right-side inspector;
- multiple side terminals per context;
- direct PTY WebSocket streaming;
- per-context tab selection and ordering;
- persistent mounting while switching terminal tabs, Agent Manager contexts, Diff, and PR views.
The extension-side path is currently:
```text
webview terminal.create
-> TerminalRouter
-> TerminalManager
-> legacy client.pty.create()
-> kilo serve PTY
-> WebSocket
-> xterm.js
```
It currently creates only default interactive shells. It does not expose command, args, or env.
### Existing backend capability
The canonical PTY service already accepts:
```ts
{
command?: string
args?: string[]
cwd?: string
title?: string
env?: Record<string, string>
}
```
It also retains bounded output, publishes `pty.exited` with an exit code, and keeps exited PTY metadata until removal. The canonical SDK surface is `client.v2.pty` under `/api/pty`.
## Proposed Architecture
### One execution backend
Use the existing `kilo serve` PTY service as the only new script execution backend. Do not add another `child_process`, `node-pty`, VS Code pseudoterminal, or webview process runner.
The new flow is:
```text
SetupScriptRunner or RunController
-> PTY-backed execution adapter
-> ScriptTerminalManager
-> client.v2.pty.create(command, args, cwd, env)
-> canonical PTY WebSocket
-> existing Agent Manager side terminal/xterm.js
```
The executable and argument array are resolved by trusted extension code using the existing platform-specific builders. The webview receives only terminal display and attachment metadata.
### Separate system terminals from user terminals
Add a provider-owned `ScriptTerminalManager` next to the current `TerminalManager`.
Do not send setup or Run processes through the existing webview-created `terminal.created` flow. That flow requires a pending webview `createId` and correctly closes unsolicited terminals. Setup can begin before the webview requests a terminal, so it needs a separate synchronization path.
Suggested record:
```ts
type ScriptTerminalKind = "setup" | "run"
type ScriptTerminalState = "starting" | "running" | "stopping" | "exited" | "failed"
interface ScriptTerminalRecord {
terminalId: string
ptyID: string
worktreeId: string | null
kind: ScriptTerminalKind
title: string
cwd: string
wsUrl: string
state: ScriptTerminalState
exitCode?: number
startedAt: number
endedAt?: number
}
```
Registry rules:
- one active `Run` record per context;
- one `Setup` record per worktree setup attempt;
- a new Run replaces the previous exited Run record after removing its retained PTY;
- user-created `Terminal N` tabs remain independently managed by `TerminalRouter`;
- script records survive `TerminalRouter.dispose()` and webview reloads;
- extension/backend shutdown removes all remaining script PTYs.
### Completion and race handling
Subscribe to global `pty.exited` events through `KiloConnectionService` and map backend PTY IDs to script records.
Account for a fast process that exits before the extension registers the returned PTY:
1. Create the PTY.
2. Store the record and backend ID.
3. Immediately call canonical `pty.get()`.
4. If it is already exited, finish from the returned status and exit code.
5. Otherwise rely on `pty.exited`.
On connection restoration, reconcile every running script record with `pty.get()`. A missing PTY is an execution failure, not a successful exit.
### Webview synchronization
Add an extension-to-webview script-terminal snapshot/update protocol, for example:
```ts
type: "agentManager.scriptTerminals"
terminals: ScriptTerminalView[]
```
Send a full snapshot:
- when a script terminal is created;
- when status or exit code changes;
- after `agentManager.requestState`;
- after the webview reloads or reattaches.
The webview terminal state adds script terminals without requiring a pending `createId`. A script terminal is still rendered by the existing `TerminalTab` and side-terminal layer.
Do not send executable paths, args, arbitrary env, or script contents to the webview.
## Required PTY Hardening
These are prerequisites for claiming behavior comparable to VS Code Tasks.
### Do not mutate explicit command arguments
`packages/core/src/pty.ts` currently appends `-l` whenever the executable looks like a login shell. With `command: "sh"` and `args: [scriptPath]`, that produces `sh scriptPath -l`, making `-l` a script argument.
Only add login-shell arguments when the caller did not provide an explicit command. Explicit `command` plus `args` must reach the process unchanged.
### Terminate the process tree
PTY removal currently calls only the PTY process's `kill()`. That does not prove that child and grandchild processes are terminated.
Reuse or generalize `packages/core/src/shell.ts` `killTree()` semantics:
- Windows: `taskkill /pid <pid> /f /t`, hidden window;
- Linux/macOS: signal the process group, then escalate from `SIGTERM` to `SIGKILL`;
- retain the direct PTY kill as a fallback.
Run Stop, worktree deletion, setup timeout, tab close, extension shutdown, and backend shutdown must all use the same tree-termination path.
### Replay exited output
Canonical PTYs retain up to 2 MiB of output and exited metadata, but `Pty.attach()` currently rejects exited sessions. A quick setup can finish before the webview attaches, and a webview reload can occur after a Run exits.
Allow a read-only attachment to an exited retained PTY:
1. replay the requested bounded buffer;
2. send cursor/status metadata;
3. close normally with the exit code available through the state protocol;
4. reject writes after exit.
Keep legacy `/pty` behavior unchanged. Script terminals use canonical `/api/pty`.
### Keep shared-file changes isolated
The PTY hardening touches shared upstream-owned code. Keep the changes minimal, use `kilocode_change` annotations where required, and run the opencode annotation and Promise-facade guards.
## Security Model
### Workspace Trust
Before setup or Run script execution, require `vscode.workspace.isTrusted`. If the workspace is restricted, show the standard trust-management action and do not create a PTY.
VS Code blocks terminals and Tasks in Restricted Mode. Moving execution behind `kilo serve` must not bypass that boundary.
### Script path validation
Run scripts already require a regular file and reject symlinks that resolve outside the root `.kilo` directory. Extract and reuse this validation for setup scripts.
Both script systems must:
- accept only the existing fixed platform-specific filenames;
- require a regular file;
- reject directories, devices, and other special files;
- reject a symlink whose real target escapes the root `.kilo` directory;
- use an absolute script path and validated absolute cwd.
### Command construction
- Resolve executable and args in extension code.
- Pass args as an array to `pty.create()`.
- Never concatenate a POSIX/PowerShell command string.
- Keep the existing `cmd.exe` path quoting helper and add paths-with-spaces and quotes tests.
- Never send a script command by `sendText`, xterm paste, or WebSocket input.
### Environment
Use one environment builder for setup and Run:
- Linux/macOS: cached login-shell environment, preserving user PATH tools such as Homebrew, nvm, pyenv, and Cargo;
- Windows: extension-host process environment;
- overlay `WORKTREE_PATH` and `REPO_PATH`;
- retain PTY-enforced stripping of `KILO_SERVER_PASSWORD` and `KILO_SERVER_USERNAME`;
- do not serialize the environment into webview messages or persisted Agent Manager state.
This intentionally improves setup parity. Setup currently lacks the login-shell environment used by Run.
### Failure behavior
- A PTY create error does not trigger a second execution path.
- A missing exit code is not treated as success.
- A server disconnect marks the script indeterminate until canonical status reconciliation completes.
- If process-tree termination cannot be confirmed, keep the UI in an error/stopping state and log the failure.
## UX
### Run
When the user clicks Run or presses `Cmd/Ctrl+E`:
1. Keep current script discovery/configuration behavior.
2. Open the Agent Manager terminal inspector.
3. Create or replace the semantic `Run` side tab for the current context.
4. Activate and focus it so interactive scripts can accept input.
5. Keep the existing worktree card and toolbar status synchronized.
While running:
- the tab shows a spinner;
- Run changes to Stop as today;
- pressing Run/Stop requests process-tree termination;
- closing the running Run tab means Stop and close;
- hiding the inspector does not stop the process.
After exit:
- exit `0` shows success;
- nonzero exit shows failure and the exit code;
- output remains available until the tab is closed or a new Run replaces it.
### Setup
When setup begins:
1. Add the new worktree context to the UI as today.
2. Open its `Setup` side tab and show live output.
3. Keep the existing setup/session sequencing unchanged.
4. Continue session creation after success or failure, preserving current best-effort behavior.
While setup runs:
- the tab shows a spinner;
- the terminal can accept input if the script prompts;
- the tab cannot be destroyed accidentally; users may hide the inspector;
- the five-minute timeout stops the process tree and marks the tab failed.
After setup exits, the tab becomes closable and retains its output.
### Rendering before a session exists
Setup begins before the first session exists in a new worktree. Update Agent Manager's empty-context logic so a context with a side script terminal renders the detail/inspector host even without a session tab.
Do not place the current blocking setup overlay above the terminal. Keep progress visible in the sidebar/worktree state and in the Setup tab itself.
### Titles and ordering
- Keep `Setup` and `Run` as semantic labels; do not replace them with OSC shell titles.
- User-created terminals continue to use OSC title updates.
- System terminals participate in the existing side tab strip and can retain stable positions.
- The `+` button always creates a user terminal and never another Run or Setup process.
### Accessibility
- Announce starting, running, stopped, succeeded, and failed state changes.
- Include the exit code in accessible status text.
- Preserve existing keyboard tab navigation and terminal focus restoration.
- A hidden terminal inspector remains inert and `aria-hidden` while the PTY continues running.
## Cross-Platform Contract
Use the extension host's platform. In WSL, Remote SSH, and Dev Containers this means the remote Linux environment and POSIX script names, not native Windows script names.
| Environment | Script execution | Important checks |
|---|---|---|
| macOS | `sh <script>` through PTY | GUI-launch PATH, Homebrew/nvm, process-group stop |
| Linux | `sh <script>` through PTY | PATH, signals, child/grandchild stop |
| Windows | PowerShell first, then CMD/BAT through ConPTY | spaces/non-ASCII paths, no console flash, `taskkill /t` |
| WSL | Linux path and scripts in remote extension host | no Windows executable/path leakage, WebSocket forwarding |
| Remote SSH/Container | remote platform and filesystem | loopback WebSocket forwarding and reconnect |
The current embedded terminal transport builds a loopback WebSocket URL directly. Native Windows/Linux/macOS are unaffected, but Remote SSH, WSL, and Dev Containers require an explicit test. If VS Code does not forward the random backend port reliably, route it with the supported webview port mapping or `vscode.env.asExternalUri` and update CSP narrowly for the resolved origin. Do not add a second process runner as a remote fallback.
## Implementation Phases
### Phase 1: Backend safety foundation
- [ ] Prevent login-argument mutation for explicit PTY commands.
- [ ] Add read-only replay attachment for exited canonical PTYs.
- [ ] Add cross-platform PTY process-tree termination.
- [ ] Add core and canonical HTTP/WebSocket tests.
- [ ] Regenerate the SDK only if the public endpoint schema changes.
Likely files:
- `packages/core/src/pty.ts`
- `packages/core/src/shell.ts`
- `packages/core/src/pty/pty.ts`
- `packages/server/src/handlers/pty.ts`
- relevant `packages/core/test/pty/` and server PTY tests
### Phase 2: Script terminal runtime
- [ ] Add a vscode-free `ScriptTerminalManager`.
- [ ] Launch through `client.v2.pty.create()` with explicit command, args, cwd, and env.
- [ ] Build canonical WebSocket attachment URLs.
- [ ] Subscribe to `pty.exited` and reconcile fast exits.
- [ ] Implement stop, remove, timeout, worktree-delete, reload, and shutdown handling.
- [ ] Keep the runtime alive across webview and `TerminalRouter` recreation.
Likely files:
- new `packages/kilo-vscode/src/agent-manager/script-terminal-manager.ts`
- `packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts`
- `packages/kilo-vscode/src/agent-manager/terminal-routing.ts` or a small shared URL helper
- `packages/kilo-vscode/src/agent-manager/types.ts`
### Phase 3: Swap execution adapters
- [ ] Keep `SetupScriptRunner`, `RunController`, and `RunScriptManager`.
- [ ] Replace `executeVscodeTask` with a PTY-backed setup adapter.
- [ ] Replace `startVscodeRunTask` with a PTY-backed Run adapter.
- [ ] Reuse one environment builder for setup and Run.
- [ ] Add Workspace Trust gating.
- [ ] Reuse Run's regular-file and confined-symlink validation for setup.
- [ ] Make setup timeout terminate the PTY process tree.
Likely files:
- `packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts`
- `packages/kilo-vscode/src/agent-manager/SetupScriptService.ts`
- `packages/kilo-vscode/src/agent-manager/run/controller.ts`
- `packages/kilo-vscode/src/agent-manager/run/service.ts`
- `packages/kilo-vscode/src/agent-manager/task-runner.ts`
- `packages/kilo-vscode/src/agent-manager/run/task.ts`
After all callers migrate and checks pass, remove the obsolete VS Code Task adapters rather than leaving two automatic execution paths.
### Phase 4: Protocol and side-panel UI
- [ ] Add script-terminal snapshot/update messages.
- [ ] Hydrate script terminals independently of webview create IDs.
- [ ] Add `kind`, state, and exit metadata to side terminal state.
- [ ] Render semantic Setup/Run tabs with status indicators.
- [ ] Preserve script output across context switches and webview reloads.
- [ ] Render the terminal inspector when setup exists before a session.
- [ ] Route Run/Stop and tab-close actions to `ScriptTerminalManager`.
- [ ] Add i18n, accessibility coverage, and visual stories.
Likely files:
- `packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts`
- `packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts`
- `packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts`
- `packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts`
- `packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx`
- `packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx`
- `packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx`
- `packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx`
- Agent Manager i18n, CSS, stories, and accessibility tests
### Phase 5: Remove bottom-panel dependency
- [ ] Confirm setup and Run no longer create VS Code Tasks.
- [ ] Confirm neither action executes `workbench.action.terminal.toggleTerminal` or reveals the Panel.
- [ ] Keep ordinary manual-shell destination behavior unchanged.
- [ ] Add a user-facing changeset and update Agent Manager script documentation.
## Automated Validation
### Extension unit tests
- [ ] Linux/macOS setup and Run use `sh` plus one absolute script-path argument.
- [ ] Windows PowerShell uses the existing fixed switches and `-File` argument.
- [ ] Windows CMD/BAT handles paths with spaces, quotes, and non-ASCII characters.
- [ ] Setup and Run receive correct cwd, `WORKTREE_PATH`, and `REPO_PATH`.
- [ ] Setup rejects directories, devices, and escaping symlinks.
- [ ] Restricted workspaces cannot execute setup or Run.
- [ ] A fast exit before registration is reconciled correctly.
- [ ] Stop during startup cannot leave an unmanaged process.
- [ ] Worktree deletion and provider disposal stop the process tree.
- [ ] Setup timeout stops the process tree and records failure.
- [ ] A PTY creation error does not invoke a fallback runner.
### Backend integration tests
- [ ] Explicit command args are unchanged, with no injected `-l`.
- [ ] stdout/stderr and interactive input work through the PTY WebSocket.
- [ ] Exit `0` and nonzero exit codes are retained and emitted.
- [ ] A client can replay output after process exit.
- [ ] Removing an exited PTY succeeds.
- [ ] Stopping a script removes a spawned child and grandchild.
- [ ] Windows ConPTY tests run in Windows CI instead of being skipped.
### Webview tests
- [ ] Unsolicited system-terminal snapshots are accepted without a pending `createId`.
- [ ] User terminal create-race protections remain unchanged.
- [ ] Setup appears before a session exists.
- [ ] Run is scoped independently to Local and each worktree.
- [ ] System terminals survive context switching and webview state rehydration.
- [ ] Starting a new Run replaces the previous exited Run terminal only.
- [ ] Closing a running Run routes through Stop.
- [ ] Setup/Run OSC title changes do not replace semantic labels.
- [ ] Status indicators and accessible announcements match runtime state.
### Repository checks
Run the smallest relevant checks while iterating, then before completion:
```text
packages/kilo-vscode: bun run typecheck
packages/kilo-vscode: bun run lint
packages/kilo-vscode: targeted unit tests, then bun run test:unit
packages/kilo-vscode: bun run knip
packages/kilo-vscode: bun run check-kilocode-change
repo root: bun run script/check-opencode-annotations.ts --worktree
repo root: bun run script/check-opencode-promise-facades.ts
packages/opencode: targeted PTY/server tests and bun run typecheck
```
Run source-link extraction if implementation or docs introduce/change URLs in guarded packages.
## Manual Validation Matrix
Use `vscode-self-test` for the local extension flow, then verify native platform behavior where CI cannot exercise the real UI.
### Every platform
- [ ] Keep the bottom VS Code panel closed.
- [ ] Create a worktree with a setup script that prints, waits, accepts input, and exits `0`.
- [ ] Repeat with setup exit `1`; output remains visible and session creation continues.
- [ ] Run a short script and verify success/failure status and retained output.
- [ ] Run a long-lived dev server, switch worktrees, return, and verify it is still attached.
- [ ] Stop the dev server and verify its child process is gone.
- [ ] Reload the Agent Manager webview during a Run and verify output/status recovery.
- [ ] Close and reopen Agent Manager while a Run is active and verify runtime rehydration.
- [ ] Delete a worktree with an active Run and verify process cleanup.
- [ ] Confirm ordinary terminal tabs and the manual-shell destination dropdown still work.
### Windows
- [ ] Test `.ps1`, `.cmd`, and `.bat` precedence.
- [ ] Test a repository path containing spaces and non-ASCII characters.
- [ ] Confirm no console window flashes.
- [ ] Confirm Stop removes child processes through `taskkill /t` semantics.
### Linux and macOS
- [ ] Test login-shell PATH tools not present in a minimal GUI environment.
- [ ] Confirm no extra `-l` reaches the script as `$1`.
- [ ] Confirm Stop terminates the process group and descendants.
### WSL and Remote SSH
- [ ] Confirm Linux script selection in WSL.
- [ ] Confirm terminal WebSocket attachment reaches the remote backend.
- [ ] Disconnect/reconnect and verify status reconciliation or a clear failure state.
## Acceptance Criteria
The work is complete when:
1. Setup and Run never reveal the bottom VS Code panel.
2. Both scripts run through a real PTY with explicit executable and argv on Windows, Linux, and macOS.
3. Run and setup output is live in named Agent Manager side tabs and remains available after exit.
4. Run status and Stop behavior remain synchronized with the existing Agent Manager controls.
5. Setup preserves its current best-effort sequencing and has a real terminating timeout.
6. Workspace Trust, script validation, credential stripping, and process-tree cleanup are enforced.
7. Webview reloads and context switches do not orphan active script processes.
8. Existing user-created side terminals, terminal tabs, and manual VS Code terminal selection do not regress.
## Out of Scope
- Running setup asynchronously with the first agent session.
- Persisting terminal output or live process records to `.kilo/agent-manager.json`.
- Recovering a process after the entire extension host or `kilo serve` process restarts.
- Per-worktree script overrides.
- Multiple simultaneous Run scripts for one context.
- Automatic Run retries or Run history.
- Replacing the ordinary user-terminal destination preference.
@@ -0,0 +1,164 @@
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
export type Process = Pick<Proc, "pid" | "onExit" | "kill">
export type Runtime = {
readonly platform: NodeJS.Platform
readonly taskkill: (file: string, args: string[], opts: { stdio: "ignore"; windowsHide: true }) => Promise<boolean>
readonly tree: () => Promise<Array<{ pid: number; parent: number }>>
readonly alive: (pid: number) => boolean
readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void
readonly sleep: (ms: number) => Promise<void>
}
const runtime: Runtime = {
platform: process.platform,
taskkill,
tree,
alive: (pid) => {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
},
signal: (pid, signal) => process.kill(pid, signal),
sleep,
}
function direct(proc: Process, signal?: "SIGTERM" | "SIGKILL") {
try {
proc.kill(signal)
} catch (err) {
log.warn("failed to kill PTY directly", { err, pid: proc.pid, signal })
}
}
function descendants(root: number, rows: Array<{ pid: number; parent: number }>) {
const children = new Map<number, number[]>()
for (const row of rows) {
const list = children.get(row.parent) ?? []
list.push(row.pid)
children.set(row.parent, list)
}
const seen = new Set<number>()
const collect = (pid: number): number[] => {
const result: number[] = []
for (const child of children.get(pid) ?? []) {
if (seen.has(child)) continue
seen.add(child)
result.push(...collect(child), child)
}
return result
}
return collect(root)
}
async function family(root: number, input: Runtime) {
const rows = await input.tree().catch((err) => {
log.debug("failed to inspect PTY process tree", { err, pid: root })
return []
})
return [...descendants(root, rows), root]
}
function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", input: Runtime) {
for (const pid of pids) {
let sent = false
for (const target of [-pid, pid]) {
try {
input.signal(target, value)
sent = true
} catch (err) {
log.debug("failed to signal PTY process", { err, pid: target, signal: value })
}
}
if (pid === proc.pid && !sent) direct(proc, value)
}
}
async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) {
return await new Promise<Array<{ pid: number; parent: number }>>((resolve) => {
try {
const child = spawn(file, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true })
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 }) {
return await new Promise<boolean>((resolve) => {
try {
const child = spawn(file, args, opts)
child.once("exit", (code) => resolve(code === 0))
child.once("error", (err) => {
log.warn("taskkill failed", { err })
resolve(false)
})
} catch (err) {
log.warn("failed to start taskkill", { err })
resolve(false)
}
})
}
export async function terminate(proc: Process, input: Runtime = runtime): Promise<void> {
const state = { exited: false }
const listener = proc.onExit(() => {
state.exited = true
})
try {
if (!proc.pid) {
direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
return
}
if (input.platform === "win32") {
const killed = await input.taskkill("taskkill", ["/pid", String(proc.pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
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"
+36 -23
View File
@@ -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<object, Subscriber>
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, {})
}
// kilocode_change end
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) teardown(session)
sessions.clear()
exitOrder.length = 0
}),
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
@@ -0,0 +1,104 @@
import { describe, expect, test } from "bun:test"
import { KiloPtyTermination } from "../../src/kilocode/pty/termination"
function fake(pid = 123) {
const calls: Array<string | undefined> = []
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 } }> = []
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 },
},
])
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" })
})
})
@@ -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, "<sh>")).toContain("<sh>")
}),
)
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 "<CHILD:%s>" "$!"; wait'])
const attached = yield* attachCollecting(info.id)
const output = yield* waitForOutput(attached.output, ">")
const match = output.match(/<CHILD:(\d+)>/)
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,30 @@ 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")
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")
@@ -339,7 +339,7 @@ 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.
@@ -22,9 +22,10 @@ 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 { ScriptTerminalManager } from "./ScriptTerminalManager"
import { buildScriptTerminalWsUrl } from "./script-terminal-url"
import { forkSession } from "./fork-session"
import { AgentManagerVisiblePresence } from "./am-visible-presence"
import { continueInWorktree } from "./continue-in-worktree"
@@ -64,6 +65,7 @@ export class AgentManagerProvider implements Disposable {
private importer: WorktreeImporter
private terminalManager: SessionTerminalManager
private terminalRouter: TerminalRouter
private scripts: ScriptTerminalManager
private run: RunController
private stateReady: Promise<void> | undefined
private statsPoller: GitStatsPoller
@@ -80,6 +82,8 @@ export class AgentManagerProvider implements Disposable {
private unsubStatus: (() => void) | undefined
private unsubFont: (() => void) | undefined
private unsubDestination: (() => void) | undefined
private unsubScript: (() => void) | undefined
private unsubConnection: (() => void) | undefined
private closing: Promise<void> | undefined
private onVisibilityChange: ((visible: boolean) => void) | undefined
// Tracks sessions owned by this panel until they are explicitly closed.
@@ -111,8 +115,29 @@ export class AgentManagerProvider implements Disposable {
post: (msg) => this.postToWebview(msg),
getTerminalFont: () => readTerminalFont(),
})
this.scripts = new ScriptTerminalManager({
getClient: () => this.connectionService.getClient(),
getClientAsync: (directory) => this.connectionService.getClientAsync(directory),
buildWsUrl: (ptyID, cwd) => {
const config = this.connectionService.getServerConfig()
if (!config) throw new Error("Not connected to CLI backend")
return buildScriptTerminalWsUrl(config, ptyID, cwd)
},
getTerminalFont: () => readTerminalFont(),
emit: (terminals) => this.postToWebview({ type: "agentManager.scriptTerminals", terminals }),
closed: (terminalId) => this.postToWebview({ type: "agentManager.terminal.closed", terminalId }),
log: (msg) => this.outputChannel.appendLine(`[RunScript] ${msg}`),
})
this.unsubScript = this.connectionService.onEvent((event) => {
if (event.type === "pty.exited") this.scripts.exited(event.properties.id, event.properties.exitCode)
if (event.type === "pty.deleted") this.scripts.deleted(event.properties.id)
})
this.unsubConnection = this.connectionService.onStateChange((state) => {
if (state === "connected") void this.scripts.sync()
})
this.unsubFont = watchTerminalFont((font) => {
this.postToWebview({ type: "agentManager.terminal.fontChanged", font })
this.scripts.snapshot()
})
this.unsubDestination = watchTerminalDestination((destination) => {
this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination })
@@ -121,7 +146,10 @@ export class AgentManagerProvider implements Disposable {
root: () => this.getRoot(),
state: () => this.getStateManager(),
open: (file) => this.host.openDocument(file),
start: startVscodeRunTask,
start: async (config, done) => {
if (!this.host.isTrusted()) throw new Error("Trust the workspace before running scripts")
return this.scripts.start("run", config, done)
},
post: (status) => this.postToWebview({ type: "agentManager.runStatus", ...status }),
error: (message) => this.postToWebview({ type: "error", message }),
log: (msg) => this.outputChannel.appendLine(`[RunScript] ${msg}`),
@@ -412,6 +440,7 @@ export class AgentManagerProvider implements Disposable {
if (diff !== undefined) return diff
const bridge = this.onBridgeMessage(m)
if (bridge !== undefined) return bridge
if (this.scripts.intercept(m)) return null
if (this.terminalRouter.handle(m)) return null
return msg
@@ -732,6 +761,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.snapshot()
void this.stateReady
?.then(() => {
// When the folder is not a git repo (or has no folder open),
@@ -1023,11 +1053,15 @@ export class AgentManagerProvider implements Disposable {
this.log(`Worktree ${worktreeId} not found in state`)
return null
}
await this.run.remove(worktreeId)
if (!(await this.scripts.clear("run", 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 +1096,11 @@ export class AgentManagerProvider implements Disposable {
return null
}
await this.run.remove(worktreeId)
if (!(await this.scripts.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 +1963,9 @@ export class AgentManagerProvider implements Disposable {
this.unsubStatus?.()
this.unsubFont?.()
this.unsubDestination?.()
this.unsubScript?.()
this.unsubConnection?.()
await this.scripts.dispose()
this.orchestration.dispose()
this.visiblePresence.clear()
this.diffs.stop()
@@ -0,0 +1,390 @@
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<string, string>
}
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<KiloClient>
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<void>
}
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 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<string, Entry>()
private readonly terminals = new Map<string, Entry>()
private readonly ptys = new Map<string, Entry>()
private readonly early = new Map<string, number>()
constructor(private readonly deps: ScriptTerminalDeps) {}
async start(
kind: ScriptTerminalKind,
config: ScriptTerminalConfig,
done: (exit: ScriptTerminalExit) => void,
): Promise<RunHandle> {
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()
const exit = this.early.get(entry.ptyID)
if (exit !== undefined) {
this.early.delete(entry.ptyID)
this.finishExited(entry, exit)
}
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) {
if (this.early.size >= 100) {
const first = this.early.keys().next().value
if (typeof first === "string") this.early.delete(first)
}
this.early.set(ptyID, exitCode)
return
}
this.finishExited(entry, exitCode)
}
deleted(ptyID: string): void {
this.early.delete(ptyID)
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()
}
async sync(): Promise<void> {
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<boolean> {
const entry = this.entries.get(key(kind, worktreeId))
if (!entry) return true
return this.close(entry.terminalId)
}
async close(terminalId: string): Promise<boolean> {
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<void> {
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<void> {
await Promise.all([...this.terminals.keys()].map((terminalId) => this.close(terminalId)))
}
private async reconcile(entry: Entry, client: KiloClient): Promise<void> {
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<void> {
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<void> {
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<void> {
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) {
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<string> {
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)
}
}
@@ -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<typeof vi.fn> }
activeSessionId: string | undefined
naming: { prompt: ReturnType<typeof vi.fn> }
scripts: { intercept: ReturnType<typeof vi.fn>; snapshot: ReturnType<typeof vi.fn> }
terminalRouter: { handle: ReturnType<typeof vi.fn> }
stateReady: Promise<void> | undefined
contextTarget: ReturnType<typeof vi.fn>
@@ -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()
@@ -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 }
@@ -14,11 +14,13 @@ export interface RunTaskConfig {
env: Record<string, string>
}
interface TaskExit {
export interface RunTaskExit {
exitCode?: number
stopped?: boolean
error?: string
}
type StartTask = (config: RunTaskConfig, done: (exit: TaskExit) => void) => Promise<RunHandle>
export type StartTask = (config: RunTaskConfig, done: (exit: RunTaskExit) => void) => Promise<RunHandle>
interface Options {
root: () => string | undefined
@@ -110,17 +112,17 @@ 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.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<void> {
return this.manager.remove(worktreeId)
}
dispose(): void {
@@ -4,6 +4,7 @@ export interface RunStatus {
worktreeId: string
state: RunState
exitCode?: number
stopped?: boolean
signal?: string
startedAt?: string
finishedAt?: string
@@ -11,17 +12,19 @@ export interface RunStatus {
}
export interface RunHandle {
stop(): void
stop(): void | Promise<void>
dispose?(): void
}
interface Entry {
status: RunStatus
handle?: RunHandle
task?: Promise<RunHandle>
}
interface FinishOptions {
exitCode?: number
stopped?: boolean
signal?: string
error?: string
}
@@ -42,6 +45,7 @@ export class RunScriptManager {
) {}
async start(worktreeId: string, start: () => Promise<RunHandle>): Promise<boolean> {
this.removed.delete(worktreeId)
const current = this.entries.get(worktreeId)
if (current && current.status.state !== "idle") return false
@@ -56,21 +60,34 @@ 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) {
if (this.removed.has(worktreeId)) {
try {
await handle.stop()
} catch (error) {
this.log(`Failed to stop removed run script for ${worktreeId}: ${message(error)}`)
}
}
handle.dispose?.()
return true
}
entry.handle = handle
if (entry.status.state === "stopping") handle.stop()
if (entry.status.state === "stopping") {
void Promise.resolve(handle.stop()).catch((error) => {
this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`)
})
}
} catch (error) {
this.finish(worktreeId, { error: message(error) })
}
return true
}
stop(worktreeId: string): void {
async stop(worktreeId: string): Promise<void> {
const entry = this.entries.get(worktreeId)
if (!entry || entry.status.state === "idle" || entry.status.state === "stopping") return
@@ -82,7 +99,7 @@ export class RunScriptManager {
if (!entry.handle) return
try {
entry.handle.stop()
await entry.handle.stop()
} catch (error) {
this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`)
}
@@ -100,6 +117,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,18 +133,36 @@ export class RunScriptManager {
return [...this.entries.values()].map((entry) => entry.status)
}
remove(worktreeId: string): void {
async remove(worktreeId: string): Promise<void> {
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?.status.state !== "idle" && handle) {
try {
await handle.stop()
} catch (error) {
this.log(`Failed to stop removed run script for ${worktreeId}: ${message(error)}`)
}
}
handle?.dispose?.()
}
dispose(): void {
for (const entry of this.entries.values()) {
for (const [id, entry] of this.entries) {
this.removed.add(id)
if (entry.status.state !== "idle") {
try {
entry.handle?.stop()
void Promise.resolve(entry.handle?.stop()).catch((error) => {
this.log(`Failed to stop run script during dispose: ${message(error)}`)
})
} catch (error) {
this.log(`Failed to stop run script during dispose: ${message(error)}`)
}
@@ -1,73 +0,0 @@
import * as vscode from "vscode"
import type { RunHandle } from "./manager"
const GRACE_MS = 250
export interface RunTaskConfig {
worktreeId: string
branch: string
command: string
args: string[]
cwd: string
env: Record<string, string>
}
export interface RunTaskExit {
exitCode?: number
}
export async function startVscodeRunTask(config: RunTaskConfig, done: (exit: RunTaskExit) => void): Promise<RunHandle> {
const proc = new vscode.ProcessExecution(config.command, config.args, {
cwd: config.cwd,
env: config.env,
})
const task = new vscode.Task(
{ type: "kilo-worktree-run" },
vscode.TaskScope.Workspace,
`Run: ${config.branch}`,
"Kilo Code",
proc,
[],
)
task.presentationOptions = {
reveal: vscode.TaskRevealKind.Always,
panel: vscode.TaskPanelKind.Dedicated,
clear: true,
showReuseMessage: false,
}
const execution = await vscode.tasks.executeTask(task)
let closed = false
let cleaned = false
let grace: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
if (cleaned) return
cleaned = true
processListener.dispose()
endListener.dispose()
if (grace) clearTimeout(grace)
}
const finish = (exit: RunTaskExit = {}) => {
if (closed) return
closed = true
cleanup()
done(exit)
}
const processListener = vscode.tasks.onDidEndTaskProcess((event) => {
if (event.execution !== execution) return
finish({ exitCode: event.exitCode ?? undefined })
})
const endListener = vscode.tasks.onDidEndTask((event) => {
if (event.execution !== execution || closed) return
grace = setTimeout(() => finish(), GRACE_MS)
})
return {
stop: () => execution.terminate(),
dispose: cleanup,
}
}
@@ -0,0 +1,16 @@
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",
auth_token: token,
})
return `${base}/api/pty/${encodeURIComponent(ptyID)}/connect?${query.toString()}`
}
@@ -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)
@@ -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 {
@@ -60,6 +60,7 @@ 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")
function readAllCss(): string {
return CSS_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n")
@@ -454,6 +455,29 @@ 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.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")
expect(provider()).not.toContain("startVscodeRunTask")
})
it("clears retained Run terminals before removing worktree state", () => {
for (const name of ["onDeleteWorktree", "onRemoveStaleWorktree"]) {
const text = body(name)
expect(text).toContain('this.scripts.clear("run", worktreeId)')
expect(text.indexOf('this.scripts.clear("run", worktreeId)')).toBeLessThan(text.indexOf("state.removeWorktree"))
}
})
// -- onDeleteWorktree invariants -------------------------------------------
/**
@@ -791,9 +815,6 @@ const VSCODE_ALLOWED: Record<string, { note: string }> = {
"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",
@@ -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)" })
})
})
@@ -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<string | null>(initial)
const state = createTerminalState(selection)
const posted: Array<Record<string, unknown>> = []
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<string, unknown>),
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()
@@ -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,7 +123,7 @@ 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([])
@@ -0,0 +1,338 @@
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<string, string>
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<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
function deferred<T>() {
let resolve: (value: T) => void = () => undefined
const promise = new Promise<T>((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<PtyResponse>
get?: () => Promise<PtyResponse>
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("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("queues an exit event that arrives before create registration", async () => {
const gate = deferred<PtyResponse>()
const ctx = harness({ create: async () => gate.promise })
const done: unknown[] = []
const started = ctx.manager.start("run", config, (exit) => done.push(exit))
await wait()
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("stops a PTY when stop races startup", async () => {
const gate = deferred<PtyResponse>()
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)
})
})
@@ -1108,6 +1108,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) => {
@@ -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;
@@ -104,6 +104,7 @@ export const SideTerminalPanel: Component<Props> = (props) => {
<TerminalTabChrome
label={props.state.title(term.id) ?? term.title}
tooltip={props.state.title(term.id) ?? term.title}
status={props.state.scriptStatus(term.id)}
active={props.state.sideActiveFor(props.contextKey()) === term.id}
role="tab"
selected={props.state.sideActiveFor(props.contextKey()) === term.id}
@@ -12,15 +12,19 @@
import { Component, Show, type JSX } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { useLanguage } from "../../src/context/language"
import { SortableTabContainer } from "../../src/components/chat/TabDnd"
import { parseBindingTokens } from "../keybind-tokens"
import { terminalChrome } from "./chrome"
import type { ScriptTerminalStatus } from "./state"
export const TerminalTabChrome: Component<{
label: string
tooltip: string
status?: ScriptTerminalStatus
keybind?: string
closeKeybind?: string
active: boolean
@@ -33,19 +37,27 @@ export const TerminalTabChrome: Component<{
onClose: (e: MouseEvent) => 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 (
<div class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""}`}>
<div
class="am-tab-target"
role={props.role}
aria-selected={props.selected}
aria-label={chrome().tooltip}
tabIndex={props.tabIndex}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
onKeyDown={props.onKeyDown}
>
<TooltipKeybind
title={props.tooltip}
title={chrome().tooltip}
keybind={props.keybind ?? ""}
placement="bottom"
gutter={8}
@@ -53,8 +65,10 @@ export const TerminalTabChrome: Component<{
openDelay={0}
>
<span class="am-tab-title">
<span class="am-tab-icon">
<Icon name="console" size="small" />
<span class="am-tab-icon" data-run-status={chrome().icon}>
<Show when={chrome().icon === "spinner"} fallback={<Icon name={icon()} size="small" />}>
<Spinner class="am-terminal-tab-spinner" />
</Show>
</span>
<span class="am-tab-label">{props.label}</span>
</span>
@@ -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<{
<TerminalTabChrome
label={props.label}
tooltip={props.tooltip}
status={props.status}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
active={props.active}
@@ -0,0 +1,23 @@
import type { ScriptTerminalStatus } from "./state"
export type TerminalChromeIcon = "console" | "spinner" | "success" | "failure"
export interface TerminalChrome {
icon: TerminalChromeIcon
tooltip: string
}
/** Keep Run status in the existing tab chrome rather than adding another layout. */
export function terminalChrome(title: string, status: ScriptTerminalStatus | undefined): TerminalChrome {
if (!status) return { icon: "console", tooltip: title }
if (status.state === "running") return { icon: "spinner", tooltip: `${title} (Running)` }
if (status.state === "stopping") return { icon: "spinner", tooltip: `${title} (Stopping)` }
if (status.state === "exited" && status.exitCode === 0)
return { icon: "success", tooltip: `${title} (Exited, code 0)` }
if (status.state === "exited")
return { icon: "failure", tooltip: `${title} (Exited, code ${status.exitCode ?? "unknown"})` }
return {
icon: "failure",
tooltip: `${title} (Failed${status.exitCode === undefined ? "" : `, code ${status.exitCode}`})`,
}
}
@@ -48,6 +48,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
id={deps.id}
label={deps.terms.title(deps.id) ?? term.title}
tooltip={deps.terms.title(deps.id) ?? term.title}
status={deps.terms.scriptStatus(deps.id)}
keybind={isActive() ? "" : deps.keybind()}
closeKeybind={deps.closeKeybind()}
active={isActive()}
@@ -15,15 +15,20 @@
import { createSignal } from "solid-js"
import type { Accessor } from "solid-js"
import { LOCAL } from "../navigate"
import type { ExtensionMessage } from "../../src/types/messages/extension-messages"
import type { ExtensionMessage, ScriptTerminalView } from "../../src/types/messages/extension-messages"
import type { TerminalDestination, TerminalFont, TerminalPlacement } from "../../src/types/messages/agent-manager"
export type { TerminalFont }
/** Prefix used for terminal tab IDs in the webview (mirrors terminal-manager.ts). */
export const TERMINAL_PREFIX = "terminal:"
export const SCRIPT_TERMINAL_PREFIX = "script:"
export const isTerminalTabId = (id: string): boolean => 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<ScriptTerminalView, "state" | "exitCode">
/** 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<string | null>): Termina
// records on purpose: replacing a record would remount its xterm via
// <For> reference inequality (see the module comment above).
const [titles, setTitles] = createSignal<Record<string, string>>({})
const [scripts, setScripts] = createSignal<Record<string, ScriptTerminalStatus>>({})
// Active side terminal per context.
const [actives, setActives] = createSignal<Record<string, string>>({})
let focusSerial = 0
@@ -228,14 +242,18 @@ export function createTerminalState(selection: Accessor<string | null>): 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<string | null>): 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,86 @@ export function createTerminalState(selection: Accessor<string | null>): 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<string, TerminalTabStateWithContext[]> = {}
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<string, ScriptTerminalStatus> = {}
for (const view of views) {
const status: ScriptTerminalStatus = { state: view.state }
if (view.exitCode !== undefined) status.exitCode = view.exitCode
states[view.terminalId] = status
}
setScripts(states)
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 +523,9 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
add,
remove,
contextFor,
isScript,
scriptStatus,
syncScripts,
forSelection,
lookup,
current,
@@ -539,6 +647,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 +697,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 +763,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<ExtensionMessage, { type: "agentManager.terminal.created" }>
type ScriptTerminalsMessage = Extract<ExtensionMessage, { type: "agentManager.scriptTerminals" }>
function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) {
const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId
@@ -682,6 +804,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 +823,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)
@@ -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
+1
View File
@@ -178,6 +178,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: true, // kilocode_change
})
.pipe(
Effect.catchTags({