mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
feat(agent-manager): run project scripts in the embedded side terminal
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user