diff --git a/.changeset/agent-manager-stop-session.md b/.changeset/agent-manager-stop-session.md new file mode 100644 index 0000000000..d892ae27db --- /dev/null +++ b/.changeset/agent-manager-stop-session.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Allow agents to stop and remove a targeted Agent Manager session. diff --git a/.changeset/align-cli-usage.md b/.changeset/align-cli-usage.md new file mode 100644 index 0000000000..4cfc10fe32 --- /dev/null +++ b/.changeset/align-cli-usage.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Improve CLI sidebar usage sections with cent-formatted costs, collapsible details, and aligned model totals. diff --git a/.changeset/background-process-display.md b/.changeset/background-process-display.md new file mode 100644 index 0000000000..e43a4e9878 --- /dev/null +++ b/.changeset/background-process-display.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Respect the Terminal Command Blocks display setting for background process tool cards. diff --git a/.changeset/calm-routes-report.md b/.changeset/calm-routes-report.md deleted file mode 100644 index dc4e9cd3af..0000000000 --- a/.changeset/calm-routes-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-gateway": patch ---- - -Show the actual routed model and upstream cost for Kilo Gateway Anthropic Messages and OpenAI Responses requests, including requests served through OpenRouter. diff --git a/.changeset/chat-bidi-text.md b/.changeset/chat-bidi-text.md new file mode 100644 index 0000000000..7f984c6eea --- /dev/null +++ b/.changeset/chat-bidi-text.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support bidirectional text in chat messages so right-to-left and mixed-language conversations render in the correct direction. diff --git a/.changeset/default-worktree-dialog.md b/.changeset/default-worktree-dialog.md new file mode 100644 index 0000000000..0fd935c7e7 --- /dev/null +++ b/.changeset/default-worktree-dialog.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open the Agent Manager new-worktree dialog from primary actions and keyboard shortcuts, with an explicit quick-create action in its dropdown. diff --git a/.changeset/fast-local-recall.md b/.changeset/fast-local-recall.md deleted file mode 100644 index 3a5f43f0cd..0000000000 --- a/.changeset/fast-local-recall.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Speed up local conversation recall searches on large histories. diff --git a/.changeset/finish-deprecated-review-command.md b/.changeset/finish-deprecated-review-command.md new file mode 100644 index 0000000000..e4bf8725a6 --- /dev/null +++ b/.changeset/finish-deprecated-review-command.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep sessions usable after deprecated local review commands complete without a session status transition. diff --git a/.changeset/fix-directory-mention-expansion.md b/.changeset/fix-directory-mention-expansion.md deleted file mode 100644 index 070127412a..0000000000 --- a/.changeset/fix-directory-mention-expansion.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Restore directory `@`-mentions by listing their entries without inlining child file contents. Untrusted external directory attachments remain denied. diff --git a/.changeset/fresh-revert-resubmit.md b/.changeset/fresh-revert-resubmit.md deleted file mode 100644 index 1996ea3435..0000000000 --- a/.changeset/fresh-revert-resubmit.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Show newly submitted messages immediately after reverting a conversation. diff --git a/.changeset/fuzzy-cats-scroll.md b/.changeset/fuzzy-cats-scroll.md deleted file mode 100644 index e88d0c3e14..0000000000 --- a/.changeset/fuzzy-cats-scroll.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-ui": patch ---- - -Keep chat auto-scroll detached after a small upward wheel scroll near the latest message. diff --git a/.changeset/grok-4.5-reasoning-variants.md b/.changeset/grok-4.5-reasoning-variants.md deleted file mode 100644 index d77ad6e9d0..0000000000 --- a/.changeset/grok-4.5-reasoning-variants.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Fix Grok 4.5 reasoning variants not showing up in the model picker. diff --git a/.changeset/jetbrains-custom-provider-edit.md b/.changeset/jetbrains-custom-provider-edit.md deleted file mode 100644 index f34122179b..0000000000 --- a/.changeset/jetbrains-custom-provider-edit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Support editing custom OpenAI-compatible providers from JetBrains settings and replace their Disconnect action with Edit and Delete. Added or edited providers stay selected, and the custom provider dialog now closes after a successful save. diff --git a/.changeset/jetbrains-custom-provider-inline-errors.md b/.changeset/jetbrains-custom-provider-inline-errors.md deleted file mode 100644 index 571b1ee28e..0000000000 --- a/.changeset/jetbrains-custom-provider-inline-errors.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Fix adding a Custom OpenAI-Compatible Provider silently failing. The dialog now requires at least one model and reports save errors inline so you can correct your input and retry without re-entering the form. diff --git a/.changeset/jetbrains-model-picker-close-button.md b/.changeset/jetbrains-model-picker-close-button.md deleted file mode 100644 index 1b003ff6a1..0000000000 --- a/.changeset/jetbrains-model-picker-close-button.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Match the model picker Close button styling to JetBrains dialog primary buttons. diff --git a/.changeset/jetbrains-providers-list-actions.md b/.changeset/jetbrains-providers-list-actions.md deleted file mode 100644 index 6f6ed458f2..0000000000 --- a/.changeset/jetbrains-providers-list-actions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Use a trash icon for provider delete and show provider edit/delete actions on selection, matching the other settings lists. diff --git a/.changeset/memory-cli-ux.md b/.changeset/memory-cli-ux.md deleted file mode 100644 index c08c9fed53..0000000000 --- a/.changeset/memory-cli-ux.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": minor ---- - -Improve CLI project-memory controls, status, activity indicators, and optional recall details. diff --git a/.changeset/memory-extension-ux.md b/.changeset/memory-extension-ux.md deleted file mode 100644 index 98f0bcdda9..0000000000 --- a/.changeset/memory-extension-ux.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/kilo-memory": minor -"kilo-code": minor ---- - -Improve project-memory activity in VS Code with a task-header menu, quick actions, and optional verbose details. diff --git a/.changeset/memory-verbose-markers.md b/.changeset/memory-verbose-markers.md deleted file mode 100644 index dc3cd2b607..0000000000 --- a/.changeset/memory-verbose-markers.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@kilocode/cli": minor -"@kilocode/sdk": minor -"kilo-code": minor ---- - -Support verbose project-memory settings and show recalled memory snippets in conversation markers when enabled. diff --git a/.changeset/remote-embedders-retry-timeouts.md b/.changeset/remote-embedders-retry-timeouts.md new file mode 100644 index 0000000000..ab0db53a16 --- /dev/null +++ b/.changeset/remote-embedders-retry-timeouts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-indexing": patch +--- + +Allow remote embedder validation timeouts to retry before indexing reports a connection failure. diff --git a/.changeset/remote-session-slash-commands.md b/.changeset/remote-session-slash-commands.md new file mode 100644 index 0000000000..eb48e0c938 --- /dev/null +++ b/.changeset/remote-session-slash-commands.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Remote CLI: expose slash command discovery, execution, and `/new` session creation over the relay. `list_commands` and `send_command` (including the built-in `compact` flow) are scoped to the current session's directory. `create_session` creates a root session in that directory, attaches it to the relay heartbeat set, and returns the new session id only after the heartbeat completes so the mobile client can navigate immediately. Failures are sanitized; the command is not auto-retried and the user may retry manually after a transient relay failure. diff --git a/.changeset/retry-incomplete-responses.md b/.changeset/retry-incomplete-responses.md deleted file mode 100644 index 6c4a7a9097..0000000000 --- a/.changeset/retry-incomplete-responses.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Retry incomplete model responses that end without final output or tool activity while preserving partial answers and completed tools. diff --git a/bun.lock b/bun.lock index db676d8051..29416a0c3f 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.4.9", + "version": "7.4.11", "bin": { "opencode": "./bin/opencode", }, @@ -127,7 +127,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -141,7 +141,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "effect": "catalog:", }, @@ -153,7 +153,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node-shared": "4.0.0-beta.74", @@ -174,7 +174,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@kilocode/kilo-web-ui": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -196,7 +196,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -226,7 +226,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -262,7 +262,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.4.9", + "version": "7.4.11", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -272,7 +272,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -304,11 +304,11 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.5", + "version": "7.4.9", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -322,7 +322,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -337,7 +337,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -351,7 +351,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -388,7 +388,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -457,7 +457,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -474,7 +474,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -492,7 +492,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.4.9", + "version": "7.4.11", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -659,7 +659,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -687,7 +687,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -701,7 +701,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "semver": "^7.6.3", }, @@ -712,7 +712,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "cross-spawn": "catalog:", }, @@ -727,7 +727,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -741,7 +741,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.4.9", + "version": "7.4.11", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -791,7 +791,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.4.9", + "version": "7.4.11", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -833,6 +833,7 @@ "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", + "happy-dom": "20.8.9", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/nix/hashes.json b/nix/hashes.json index ea4deef8f3..a9a1b4d8ce 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-yNHrsBXfCWeuX13frVTjIL7U4k71wSimDtdTjoBndoY=", - "aarch64-linux": "sha256-zbRBPcPduV1a2EH4BiZWPJ+LyZMLCEPkQ3s+U7tDxtk=", - "aarch64-darwin": "sha256-NLLlE2t3vs1E5fDwyiFGoneJAdTb6uXrFuVa+LwBtlY=", - "x86_64-darwin": "sha256-BH31+FPgkt1xXJixkj+DgXIveJ49fWk4AIc3Hx+4XzM=" + "x86_64-linux": "sha256-SS/pjldHwh2DdsWAvKJ8NivbUWuzo1GOgOoPCuJEd08=", + "aarch64-linux": "sha256-57nGVTiyjyjl2/27kd1rHQLgbO3XfeZp6xXBhMXb12Q=", + "aarch64-darwin": "sha256-OTtzy5ytPFMVKTE1tqi55aKxcdvdPe1yyqthvEZrVTY=", + "x86_64-darwin": "sha256-4It5t+dcvwv6UMOFXX67Kxp2qxoDOrvHKeuWTGzYYX4=" } } diff --git a/package.json b/package.json index b65e81ace0..aa0e85c19b 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,6 @@ "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.4.9", + "version": "7.4.11", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index ea06962a01..bcf809158b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.9", + "version": "7.4.11", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index fcdf4ef42e..344ea15818 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.9", + "version": "7.4.11", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 8aa8f36cfd..8e3e2fecc7 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.9", + "version": "7.4.11", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 4c7e0a50dc..7f85174b31 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.4.9" +version = "7.4.11" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 5566f3ab24..7c8c9e0ec3 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.9", + "version": "7.4.11", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index d3ee02426b..6bf5f591b5 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.9", + "version": "7.4.11", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 5f0707a42d..8af9d791d0 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.4.9", + "version": "7.4.11", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/automate/agent-manager-workflows.md b/packages/kilo-docs/pages/automate/agent-manager-workflows.md index 928b240a77..195adb957c 100644 --- a/packages/kilo-docs/pages/automate/agent-manager-workflows.md +++ b/packages/kilo-docs/pages/automate/agent-manager-workflows.md @@ -35,7 +35,7 @@ Parallel work pays off when sessions are **independent** — neither one's outpu Every productive worktree session follows the same rhythm: -1. **Create a worktree** (`Cmd+N` / `Ctrl+N`) and describe the task. +1. **Open a new worktree dialog** (`Cmd+N` / `Ctrl+N`), then describe the task. 2. **Let the agent run.** Switch to another worktree, another session, or step away. 3. **Verify manually.** Before you trust "all tests pass", run the app with the run script (`Cmd+E` / `Ctrl+E`) or open the worktree's terminal (`Cmd+/` / `Ctrl+/`) and run the tests yourself. 4. **Review the diff** (`Cmd+D` / `Ctrl+D`). Drop inline comments, then **Send to chat** to feed them back to the agent. @@ -48,7 +48,7 @@ The single biggest lever on this loop is **keeping each worktree's scope small** ### 1. Side quest -Something unrelated came up while you are mid-task. Create a new worktree for it (`Cmd+N`), let the agent work, review when it is done. Your main work is unaffected. +Something unrelated came up while you are mid-task. Open a new worktree dialog for it (`Cmd+N`), let the agent work, review when it is done. Your main work is unaffected. ### 2. Build a skeleton, then split the work @@ -64,7 +64,7 @@ This mirrors how a human team works: agree the API contract first, then split ba For genuinely hard tasks where you do not know which approach will work: -1. Open the advanced new-worktree dialog (`Cmd+Shift+N` / `Ctrl+Shift+N`) and pick 2–4 versions. +1. Open the new-worktree dialog (`Cmd+N` / `Ctrl+N`) and pick 2–4 versions. 2. Optionally assign a different model to each. 3. Review the diffs side by side, pick the winner, apply it, discard the rest. @@ -76,7 +76,7 @@ A related pattern: use the sidebar as an investigation surface. Start two or thr ### 5. A worktree per bug -For a day of small fixes: one worktree per bug (`Cmd+N`), one branch per fix, merge each quickly so none drift. Close the worktree when the fix lands. +For a day of small fixes: one worktree per bug. Use `Cmd+N` to configure each worktree or `Cmd+Shift+N` to create one immediately from the default branch, merge each quickly so none drift. Close the worktree when the fix lands. ### 6. Multiple sessions on one branch @@ -225,8 +225,9 @@ Merge the most foundational one first. Then, in each remaining worktree, ask the | Situation | Where | |---|---| | Small, interactive task | Sidebar | -| Long task, want to do something else meanwhile | New worktree (`Cmd+N`) | -| Two or three approaches, pick the winner | Multi-version (`Cmd+Shift+N`) | +| Long task, want to do something else meanwhile | Configure a new worktree (`Cmd+N`) | +| Quick task on the default base branch | Create a new worktree (`Cmd+Shift+N`) | +| Two or three approaches, pick the winner | Configure multi-version worktrees (`Cmd+N`) | | Sidebar task outgrew the sidebar | Continue in Worktree | | Separate conversation on the same branch | New tab (`Cmd+T`) | | Long conversation, want a fresh context on same branch | New tab, summarize | diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 9812eceb1c..9651c14b2b 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -69,7 +69,7 @@ The extension uses `gh` to automatically discover PRs for each worktree branch. 2. **Branch name** — `gh pr view ` matches same-repo branches pushed to origin 3. **HEAD commit SHA** — `gh pr list --search ""` as a last resort, matching PRs whose head ref points to the exact same commit -You can also import a PR directly from the advanced new worktree dialog: open the **New Worktree** dropdown and select **Advanced**, or press `Cmd+Shift+N` (macOS) / `Ctrl+Shift+N` (Windows/Linux), switch to the **Import** tab, then paste the GitHub PR URL. The branch is checked out and the badge appears automatically. +You can also import a PR directly from the new worktree dialog: click **New Worktree** or press `Cmd+N` (macOS) / `Ctrl+N` (Windows/Linux), switch to the **Import** tab, then paste the GitHub PR URL. The branch is checked out and the badge appears automatically. #### Badge colors @@ -106,12 +106,14 @@ PR badges update automatically in the background. The active worktree refreshes ### Creating a New Worktree Session -1. Click **New Worktree** or press `Cmd+N` (macOS) / `Ctrl+N` (Windows/Linux) to create a new worktree +1. Click **New Worktree** or press `Cmd+N` (macOS) / `Ctrl+N` (Windows/Linux) to open the new worktree dialog 2. Enter a branch name (or let Kilo generate one) -3. Type your first message to start the agent +3. Type your first message, then create the worktree A new git worktree is created from your current branch. The agent works in isolation — your main branch is unaffected. +To create a worktree immediately from the default base branch, press `Cmd+Shift+N` (macOS) / `Ctrl+Shift+N` (Windows/Linux). + ### Multi-Version Mode You can run up to 4 parallel implementations of the same prompt across separate worktrees: @@ -352,8 +354,8 @@ Closing a managed worktree removes it from Agent Manager, deletes its `.kilo/wor | Shortcut (macOS) | Shortcut (Windows/Linux) | Action | |---|---|---| | `Cmd+Shift+M` | `Ctrl+Shift+M` | Open / focus Agent Manager (works from anywhere) | -| `Cmd+N` | `Ctrl+N` | New worktree | -| `Cmd+Shift+N` | `Ctrl+Shift+N` | New worktree (advanced options) | +| `Cmd+N` | `Ctrl+N` | Configure a new worktree | +| `Cmd+Shift+N` | `Ctrl+Shift+N` | Create a new worktree immediately | | `Cmd+Shift+O` | `Ctrl+Shift+O` | Import/open worktree | | `Cmd+Shift+W` | `Ctrl+Shift+W` | Close current worktree | | `Cmd+T` | `Ctrl+T` | New tab (session) in worktree | diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-200-chromium-linux.png new file mode 100644 index 0000000000..f7283e81eb --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-200-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a89d1a9a31f7de1780d772b7caf71d96f0b4b895df954c77dcd508a7febe7c9 +size 1950 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png index 60adb8adf7..ca5db9f648 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a864b6a5c0e88bc00292f8a38685acdf2ecdd9eb5d880a663ba830152b7280b -size 3523 +oid sha256:431a0c12cb844b5d4e535cc4c7bf5546fd9e66a178b8023b00978d07c291a531 +size 3919 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png index 3c28f10cc0..5fda873115 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08f1498ef61670dfec83a2de45f1b9d7008c6c9987705a39175eb990182ee5c2 -size 6156 +oid sha256:f68544cdf994abca3004e652911aa8edd77cf2bb3f91678e5dc6210022dff2f9 +size 6188 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index c587dc9842..be62870f8c 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-gateway/src/gateway-metadata.ts b/packages/kilo-gateway/src/gateway-metadata.ts index cd3012185a..ea390c576d 100644 --- a/packages/kilo-gateway/src/gateway-metadata.ts +++ b/packages/kilo-gateway/src/gateway-metadata.ts @@ -54,7 +54,7 @@ type Data = z.infer function routed(meta: Gateway) { const route = meta.routing if (!route) return - const hit = route.modelAttempts?.findLast((item) => item.success === true) + const hit = route.modelAttempts?.slice().reverse().find((item) => item.success === true) const id = hit?.canonicalSlug ?? route.canonicalSlug if (!id) return const value = id.trim() diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index db095a6f54..59bbc97467 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 84650fce1a..1188f8e026 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-indexing/src/indexing/service-factory.ts b/packages/kilo-indexing/src/indexing/service-factory.ts index 09e4706aac..837578edb9 100644 --- a/packages/kilo-indexing/src/indexing/service-factory.ts +++ b/packages/kilo-indexing/src/indexing/service-factory.ts @@ -16,7 +16,7 @@ import { VoyageEmbedder } from "./embedders/voyage" import { QdrantVectorStore } from "./vector-store/qdrant-client" import { LanceDBVectorStore } from "./vector-store/lancedb-vector-store" import { codeParser, DirectoryScanner, FileWatcher } from "./processors" -import type { ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces" +import type { AvailableEmbedders, ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces" import type { CodeIndexConfigManager } from "./config-manager" import type { CacheManager } from "./cache-manager" import type { IndexingTelemetryMeta, IndexingTelemetryReporter } from "./interfaces/telemetry" @@ -31,10 +31,28 @@ import type { IgnoreMatcher } from "./shared/load-ignore" const log = Log.create({ service: "indexing-factory" }) -function timeout(provider: string): number { - if (provider === "ollama") return OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS - return REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS -} +// RATIONALE: The OpenAI SDK applies the per-attempt timeout and retries internally. +const policy = { + openai: undefined, + openrouter: undefined, + "openai-compatible": undefined, + kilo: undefined, + gemini: undefined, + mistral: undefined, + "vercel-ai-gateway": undefined, + ollama: { + timeout: OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS, + error: "Connection to embedding service failed (timeout)", + }, + voyage: { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + error: "Connection failed. Please check the endpoint URL and network connectivity.", + }, + bedrock: { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + error: "Connection failed. Please check the endpoint URL and network connectivity.", + }, +} satisfies Record /** * Factory class responsible for creating and configuring code indexing service dependencies. @@ -128,26 +146,26 @@ export class CodeIndexServiceFactory { } public async validateEmbedder(embedder: IEmbedder): Promise<{ valid: boolean; error?: string }> { - const ms = timeout(embedder.embedderInfo.name) + const deadline = policy[embedder.embedderInfo.name] let timer: ReturnType | undefined const wait = embedder.validateConfiguration() - const fail = new Promise<{ valid: boolean; error?: string }>((resolve) => { - timer = setTimeout( - () => - resolve({ - valid: false, - error: - embedder.embedderInfo.name === "ollama" - ? "Connection to embedding service failed (timeout)" - : "Connection failed. Please check the endpoint URL and network connectivity.", - }), - ms, - ) - }) + const fail = + deadline === undefined + ? undefined + : new Promise<{ valid: boolean; error?: string }>((resolve) => { + timer = setTimeout( + () => + resolve({ + valid: false, + error: deadline.error, + }), + deadline.timeout, + ) + }) try { log.info("validating embedder", { provider: embedder.embedderInfo.name }) - const result = await Promise.race([wait, fail]) + const result = fail ? await Promise.race([wait, fail]) : await wait if (result.valid) { log.info("embedder validation succeeded", { provider: embedder.embedderInfo.name }) } diff --git a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts index e695b63511..cd6348378e 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test, mock, beforeEach } from "bun:test" import path from "path" import { mockEmbeddingsCreate, openAIMockFactory, setOpenAIConstructorHook } from "./embedders/__helpers__/openai-mock" +import { + OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, +} from "../../../src/indexing/constants" +import type { AvailableEmbedders, IEmbedder } from "../../../src/indexing/interfaces/embedder" mock.module("openai", openAIMockFactory) import { CodeIndexServiceFactory } from "../../../src/indexing/service-factory" @@ -23,6 +28,20 @@ function createFactory(input?: Partial { beforeEach(() => { mockEmbeddingsCreate.mockReset() @@ -39,6 +58,55 @@ describe("CodeIndexServiceFactory", () => { expect(factory.createEmbedder().embedderInfo).toEqual({ name: "openai-compatible" }) }) + test("lets SDK-backed embedders own validation timeouts", async () => { + const original = globalThis.setTimeout + const timer = mock((...args: Parameters) => original(...args)) + globalThis.setTimeout = timer + + try { + const factory = createFactory() + const providers = [ + "openai", + "openrouter", + "openai-compatible", + "kilo", + "gemini", + "mistral", + "vercel-ai-gateway", + ] satisfies AvailableEmbedders[] + + for (const provider of providers) { + await expect(factory.validateEmbedder(createEmbedder(provider))).resolves.toEqual({ valid: true }) + } + + expect(timer).not.toHaveBeenCalled() + } finally { + globalThis.setTimeout = original + } + }) + + test("retains factory deadlines for non-SDK embedders", async () => { + const original = globalThis.setTimeout + const timer = mock((...args: Parameters) => original(...args)) + globalThis.setTimeout = timer + + try { + const factory = createFactory() + + await factory.validateEmbedder(createEmbedder("voyage")) + await factory.validateEmbedder(createEmbedder("bedrock")) + await factory.validateEmbedder(createEmbedder("ollama")) + + expect(timer.mock.calls.map((call) => call[1])).toEqual([ + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS, + ]) + } finally { + globalThis.setTimeout = original + } + }) + test("uses default LanceDB directory when config is unset", () => { const factory = createFactory({ vectorStoreProvider: "lancedb", lancedbVectorStoreDirectory: undefined }) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 8cab465cfb..ab9bd7ff27 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 7.4.10 + +### Patch Changes + +- [#12217](https://github.com/Kilo-Org/kilocode/pull/12217) [`d6b36a0`](https://github.com/Kilo-Org/kilocode/commit/d6b36a028cc0a4b7bfd158d75e287c110e2838f7) - Support editing custom OpenAI-compatible providers from JetBrains settings and replace their Disconnect action with Edit and Delete. Added or edited providers stay selected, and the custom provider dialog now closes after a successful save. + +- [#12217](https://github.com/Kilo-Org/kilocode/pull/12217) [`6077c1c`](https://github.com/Kilo-Org/kilocode/commit/6077c1c3b36d4c5cd68f206fc146ca472d841c5e) - Fix adding a Custom OpenAI-Compatible Provider silently failing. The dialog now requires at least one model and reports save errors inline so you can correct your input and retry without re-entering the form. + +- [#12217](https://github.com/Kilo-Org/kilocode/pull/12217) [`cae3270`](https://github.com/Kilo-Org/kilocode/commit/cae3270c9dacc4097a681539cf3e07cfadceaca7) - Match the model picker Close button styling to JetBrains dialog primary buttons. + +- [#12217](https://github.com/Kilo-Org/kilocode/pull/12217) [`cae3270`](https://github.com/Kilo-Org/kilocode/commit/cae3270c9dacc4097a681539cf3e07cfadceaca7) - Use a trash icon for provider delete and show provider edit/delete actions on selection, matching the other settings lists. + ## 7.4.6 ### Patch Changes diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index 949eb170cf..f5b9fc4f71 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -264,58 +264,86 @@ class KiloBackendSessionManager( // ------ mapping (generated API model → DTO) ------ - private fun dto(s: ai.kilocode.jetbrains.api.model.Session) = SessionDto( + private fun dto(s: ai.kilocode.jetbrains.api.model.Session) = dto( id = s.id, - projectID = s.projectID, - directory = s.directory, - parentID = s.parentID, + project = s.projectID, + dir = s.directory, + parent = s.parentID, title = s.title, version = s.version, - time = SessionTimeDto( - created = time(s.id, "created", s.time.created), - updated = time(s.id, "updated", s.time.updated), - archived = s.time.archived, - ), - summary = s.summary?.let { - SessionSummaryDto( - additions = it.additions.safeInt(), - deletions = it.deletions.safeInt(), - files = it.files.safeInt(), - ) - }, + created = s.time.created, + updated = s.time.updated, + archived = s.time.archived, + summary = s.summary?.let { summary(it.additions, it.deletions, it.files) }, revert = revertDto(s.revert), ) - private fun dto(s: GlobalSession) = SessionDto( + private fun dto(s: ai.kilocode.jetbrains.api.model.Session1) = dto( id = s.id, - projectID = s.projectID, - directory = s.directory, - parentID = s.parentID, + project = s.projectID, + dir = s.directory, + parent = s.parentID, title = s.title, version = s.version, - time = SessionTimeDto( - created = time(s.id, "created", s.time.created), - updated = time(s.id, "updated", s.time.updated), - archived = s.time.archived, - ), - summary = s.summary?.let { - SessionSummaryDto( - additions = it.additions?.safeInt() ?: 0, - deletions = it.deletions?.safeInt() ?: 0, - files = it.files?.safeInt() ?: 0, - ) - }, + created = s.time.created, + updated = s.time.updated, + archived = s.time.archived, + summary = s.summary?.let { summary(it.additions, it.deletions, it.files) }, revert = revertDto(s.revert), ) + private fun dto(s: GlobalSession) = dto( + id = s.id, + project = s.projectID, + dir = s.directory, + parent = s.parentID, + title = s.title, + version = s.version, + created = s.time.created, + updated = s.time.updated, + archived = s.time.archived, + summary = s.summary?.let { summary(it.additions, it.deletions, it.files) }, + revert = revertDto(s.revert), + ) + + private fun dto( + id: String, + project: String, + dir: String, + parent: String?, + title: String, + version: String, + created: Number?, + updated: Number?, + archived: Double?, + summary: SessionSummaryDto?, + revert: SessionRevertDto?, + ) = SessionDto( + id = id, + projectID = project, + directory = dir, + parentID = parent, + title = title, + version = version, + time = SessionTimeDto( + created = time(id, "created", created), + updated = time(id, "updated", updated), + archived = archived, + ), + summary = summary, + revert = revert, + ) + + private fun summary(add: Double?, del: Double?, files: Double?) = SessionSummaryDto( + additions = count(add), + deletions = count(del), + files = count(files), + ) + private fun revertDto(s: ai.kilocode.jetbrains.api.model.SessionRevert?) = s?.let { revertDto(it.messageID, it.partID, it.snapshot, it.diff) } - private fun revertDto(s: ai.kilocode.jetbrains.api.model.GlobalSessionRevert?) = s?.let { - revertDto(it.messageID, it.partID, it.snapshot, it.diff) - } - private fun revertDto(message: String, part: String?, snapshot: String?, diff: String?) = SessionRevertDto( messageID = message, @@ -334,6 +362,8 @@ class KiloBackendSessionManager( private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8) + private fun count(value: Double?) = value?.safeInt() ?: 0 + private fun time(id: String, field: String, value: Number?): Double { if (value != null) return value.toDouble() log.warn("Session $id missing $field timestamp; defaulting to 0.0") diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt index 09c3f8cb3f..c3af05ab4a 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt @@ -124,16 +124,19 @@ internal object OpenApiSpecNormalizer { as? JsonObject ?: return root val props = schema["properties"] as? JsonObject ?: return root - val nullable = setOf("balance", "kiloPass", "currentOrgId") + val keys = setOf("balance", "kiloPass", "currentOrgId") val fixed = JsonObject(props.mapValues { (key, value) -> - if (key !in nullable) return@mapValues value - val obj = value as? JsonObject ?: return@mapValues value - // Skip if already wrapped (has anyOf containing {type:null}). - val existing = obj["anyOf"] as? JsonArray - if (existing != null && existing.any { - (it as? JsonObject)?.get("type")?.let { t -> (t as? JsonPrimitive)?.content } == "null" - }) return@mapValues value - JsonObject(mapOf("anyOf" to JsonArray(listOf(obj, JsonObject(mapOf("type" to JsonPrimitive("null"))))))) + if (key !in keys) return@mapValues value + val wrapped = nullable(value) + when (key) { + "balance" -> nullableProps(wrapped, setOf("balance")) + "kiloPass" -> nullableProps(wrapped, setOf( + "currentPeriodBaseCreditsUsd", + "currentPeriodUsageUsd", + "currentPeriodBonusCreditsUsd", + )) + else -> wrapped + } }) // Rebuild nested objects up to root. @@ -152,6 +155,29 @@ internal object OpenApiSpecNormalizer { return JsonObject(root + mapOf("paths" to newPaths)) } + private fun nullable(value: JsonElement): JsonElement { + val obj = value as? JsonObject ?: return value + val any = obj["anyOf"] as? JsonArray + if (any != null && any.any(::nullSchema)) return obj + return JsonObject(mapOf("anyOf" to JsonArray(listOf(obj, JsonObject(mapOf("type" to JsonPrimitive("null"))))))) + } + + private fun nullableProps(value: JsonElement, names: Set): JsonElement { + val obj = value as? JsonObject ?: return value + val any = obj["anyOf"] as? JsonArray + if (any != null) { + return JsonObject(obj + mapOf("anyOf" to JsonArray(any.map { nullableProps(it, names) }))) + } + val props = obj["properties"] as? JsonObject ?: return obj + val fixed = JsonObject(props.mapValues { (key, prop) -> + if (key in names) nullable(prop) else prop + }) + return JsonObject(obj + mapOf("properties" to fixed)) + } + + private fun nullSchema(value: JsonElement) = + (value as? JsonObject)?.get("type")?.let { (it as? JsonPrimitive)?.content } == "null" + /** * Deduplicate the root-level "tags" array by name — the spec validator * rejects repeated tag names even when they describe different things. diff --git a/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt b/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt index 492ea3c0a6..b9b9fb1398 100644 --- a/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt +++ b/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt @@ -148,6 +148,12 @@ class OpenApiSpecNormalizerTest { val balanceTypes = balanceAnyOf.map { (it as? JsonObject)?.get("type").let { t -> (t as? JsonPrimitive)?.content } } assert("null" in balanceTypes) { "balance anyOf should include null but got $balanceTypes" } assert(balanceAnyOf.any { it is JsonObject && "properties" in it }) { "balance anyOf should include the object schema" } + val balanceObject = balanceAnyOf.filterIsInstance().first { "properties" in it } + val balanceValue = obj(obj(balanceObject["properties"])["balance"]) + val balanceValueTypes = arr(balanceValue["anyOf"]).map { + (it as? JsonObject)?.get("type").let { t -> (t as? JsonPrimitive)?.content } + } + assert("null" in balanceValueTypes) { "inner balance value should include null but got $balanceValueTypes" } // kiloPass must be anyOf [object, null] val pass = obj(props["kiloPass"]) @@ -156,6 +162,11 @@ class OpenApiSpecNormalizerTest { val passTypes = passAnyOf.map { (it as? JsonObject)?.get("type").let { t -> (t as? JsonPrimitive)?.content } } assert("null" in passTypes) { "kiloPass anyOf should include null but got $passTypes" } assert(passAnyOf.any { it is JsonObject && "properties" in it }) { "kiloPass anyOf should include the object schema" } + val passObject = passAnyOf.filterIsInstance().first { "properties" in it } + val passProps = obj(passObject["properties"]) + val base = obj(passProps["currentPeriodBaseCreditsUsd"]) + val baseTypes = arr(base["anyOf"]).map { (it as? JsonObject)?.get("type").let { t -> (t as? JsonPrimitive)?.content } } + assert("null" in baseTypes) { "inner kiloPass value should include null but got $baseTypes" } // currentOrgId must be anyOf [string, null] val orgId = obj(props["currentOrgId"]) diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 38402890d8..8214859179 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.4.5", + "version": "7.4.9", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index a7b66b41f2..741fdcf8de 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-memory", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index 4113909502..c9533a3b77 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sandbox", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "private": true, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index a0630775ac..1ee34a5090 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 29444b544a..f465e7880a 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index 34e71420cc..8509d5a223 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -3,6 +3,10 @@ import { splitProps } from "solid-js" const icons: Record = { memory: { + viewBox: "0 0 24 24", + path: ``, + }, + database: { viewBox: "0 0 24 24", path: ``, }, diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index b36d9943e3..b711188f07 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -392,6 +392,9 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { } [data-slot="user-message-text"] { + unicode-bidi: plaintext; /* Match the prompt input */ + background-color: color-mix(in srgb, var(--text-base) 7%, transparent); + border: 1px solid transparent; transition: opacity 0.3s ease; &[data-queued] { @@ -408,6 +411,11 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { } } +body.vscode-high-contrast [data-component="user-message"] [data-slot="user-message-text"], +body.vscode-high-contrast-light [data-component="user-message"] [data-slot="user-message-text"] { + border-color: var(--border-weak-base); +} + [data-component="tool-output"] { margin-bottom: 0px; } diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index ecbc45b2fc..131874b564 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -857,7 +857,7 @@ export function UserMessageDisplay(props: {
{props.header} -
+
diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 6f29820b2a..0bedfd066f 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,31 @@ # kilo-code +## 7.4.11 + +### Minor Changes + +- [#12254](https://github.com/Kilo-Org/kilocode/pull/12254) [`30e7ec4`](https://github.com/Kilo-Org/kilocode/commit/30e7ec4ab45fac724b41ec0b4342e272e7f584d2) - Improve project-memory activity in VS Code with a task-header menu, quick actions, and optional verbose details. + +- [#12250](https://github.com/Kilo-Org/kilocode/pull/12250) [`bd69158`](https://github.com/Kilo-Org/kilocode/commit/bd69158131aafdcc2f44aede22b573c2b0432f21) - Support verbose project-memory settings and show recalled memory snippets in conversation markers when enabled. + +### Patch Changes + +- [#12287](https://github.com/Kilo-Org/kilocode/pull/12287) [`bb75928`](https://github.com/Kilo-Org/kilocode/commit/bb759287495fabc1f3fa7e0fbecff13ab4d41cb2) - Use distinct icons for context, project memory, and code indexing, and group context controls to preserve task titles in narrow VS Code sidebars. + +- [#12285](https://github.com/Kilo-Org/kilocode/pull/12285) [`9fa2e18`](https://github.com/Kilo-Org/kilocode/commit/9fa2e187047a4d2b6e76066af3c139c21851e854) - Prevent sessions from freezing after submitting or dismissing a question. + +- [#12274](https://github.com/Kilo-Org/kilocode/pull/12274) [`5180c10`](https://github.com/Kilo-Org/kilocode/commit/5180c10c4f69500ce303437646371500a71dba46) - Show newly submitted messages immediately after reverting a conversation. + +- Updated dependencies [[`319f159`](https://github.com/Kilo-Org/kilocode/commit/319f159ac333d18855a72ddb1fa61ed471ebf2d9), [`bb75928`](https://github.com/Kilo-Org/kilocode/commit/bb759287495fabc1f3fa7e0fbecff13ab4d41cb2), [`9fa8a13`](https://github.com/Kilo-Org/kilocode/commit/9fa8a13e26db552373149e1275597eb07861de91), [`30e7ec4`](https://github.com/Kilo-Org/kilocode/commit/30e7ec4ab45fac724b41ec0b4342e272e7f584d2), [`bd69158`](https://github.com/Kilo-Org/kilocode/commit/bd69158131aafdcc2f44aede22b573c2b0432f21)]: + - @kilocode/kilo-gateway@7.4.10 + - @kilocode/kilo-ui@7.4.10 + - @kilocode/kilo-memory@7.5.0 + - @kilocode/sdk@7.5.0 + - @opencode-ai/core@7.4.10 + - @kilocode/kilo-indexing@7.4.10 + - @kilocode/plugin@7.4.10 + - @opencode-ai/ui@7.4.10 + ## 7.4.9 ### Patch Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 181f8f1779..4066e4f570 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.4.9", + "version": "7.4.11", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -291,6 +291,11 @@ }, { "command": "kilo-code.new.agentManager.newWorktree", + "title": "Agent Manager: Configure New Worktree", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.agentManager.quickWorktree", "title": "Agent Manager: New Worktree", "category": "Kilo Code" }, @@ -311,7 +316,7 @@ }, { "command": "kilo-code.new.agentManager.advancedWorktree", - "title": "Agent Manager: Advanced New Worktree", + "title": "Agent Manager: Configure New Worktree", "category": "Kilo Code" }, { @@ -472,6 +477,10 @@ "command": "kilo-code.new.sidebarTitle.kiloClawOpen", "when": "false" }, + { + "command": "kilo-code.new.agentManager.advancedWorktree", + "when": "false" + }, { "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", "when": "false" @@ -713,6 +722,12 @@ "mac": "cmd+n", "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" }, + { + "command": "kilo-code.new.agentManager.quickWorktree", + "key": "ctrl+shift+n", + "mac": "cmd+shift+n", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, { "command": "kilo-code.new.agentManager.openWorktree", "key": "ctrl+shift+o", @@ -731,12 +746,6 @@ "mac": "cmd+shift+w", "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" }, - { - "command": "kilo-code.new.agentManager.advancedWorktree", - "key": "ctrl+shift+n", - "mac": "cmd+shift+n", - "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" - }, { "command": "kilo-code.new.agentManager.jumpTo1", "key": "ctrl+1", @@ -1159,8 +1168,7 @@ "pretest": "bun run compile-tests && bun run compile && bun run lint", "check-types": "tsc --noEmit", "check-types:webview": "bun script/typecheck.ts --project webview-ui/tsconfig.json", - "typecheck": "bun run check-types:extension && bun run check-types:webview", - "check-types:extension": "bun script/typecheck.ts", + "typecheck": "bun run check-types && bun run check-types:webview", "format": "prettier --write .", "format:check": "prettier --check .", "knip": "knip", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 2f9d8784a6..7c6aeddea2 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -139,6 +139,7 @@ import { import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments" +import { completesWithoutStatus } from "./kilo-provider/command-completion" import { KiloProviderMemory } from "./kilo-provider/memory" import { @@ -3413,6 +3414,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper messageID, ), ) + if (messageID && completesWithoutStatus(command)) { + this.postMessage({ type: "sessionCommandCompleted", messageID }) + } } catch (error) { console.error("[Kilo New] KiloProvider: Failed to send command:", error) this.postMessage({ diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 9a43a3a761..6e480cc212 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -194,6 +194,11 @@ export class AgentManagerProvider implements Disposable { }, stats: (refresh) => this.statsPoller.snapshot(refresh), prs: () => this.prBridge.snapshot(), + managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id), + close: async (id) => { + await this.onCloseSession(id) + this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id }) + }, log: (...args) => this.log(...args), }) this.unsubTool = this.connectionService.onEventFiltered( diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts index 52d3b18568..5d87563040 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts @@ -25,10 +25,12 @@ interface RequestBase { type Request = | (RequestBase & { operation: "overview"; filter?: OverviewFilter }) | (RequestBase & { operation: "prompt"; targetSessionID: string; prompt: string }) + | (RequestBase & { operation: "stop"; targetSessionID: string }) type Result = | { operation: "overview"; overview: Overview } | { operation: "prompt"; sessionID: string; delivered: true } + | { operation: "stop"; sessionID: string; stopped: true } interface Failure { code: FailureCode | "cancelled" | "disconnected" | "timeout" @@ -41,6 +43,8 @@ interface Options { state(): WorktreeStateManager | undefined stats(refresh?: boolean): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> prs(): Map + managed(sessionID: string): boolean + close(sessionID: string): Promise log(...args: unknown[]): void } @@ -262,17 +266,25 @@ export class AgentManagerOrchestrationBridge { }) return { result: { operation: "overview", overview: result } } } - await prompt({ - client, - root, - state, - sessionID: request.targetSessionID, - text: request.prompt, - messageID: request.id, - signal: active.controller.signal, - }) + if (request.operation === "prompt") { + await prompt({ + client, + root, + state, + sessionID: request.targetSessionID, + text: request.prompt, + messageID: request.id, + signal: active.controller.signal, + }) + if (this.disposed || active.cancelled) return + return { result: { operation: "prompt", sessionID: request.targetSessionID, delivered: true } } + } + if (!this.options.managed(request.targetSessionID)) { + throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace") + } + await this.options.close(request.targetSessionID) if (this.disposed || active.cancelled) return - return { result: { operation: "prompt", sessionID: request.targetSessionID, delivered: true } } + return { result: { operation: "stop", sessionID: request.targetSessionID, stopped: true } } } catch (error) { if (this.disposed || active.cancelled) return return { error: failure(error) } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 8c4e01f644..13d1e28e5b 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -190,6 +190,11 @@ interface SessionForkedMessage { worktreeId?: string } +interface SessionClosedMessage { + type: "agentManager.sessionClosed" + sessionId: string +} + interface MultiVersionProgressMessage { type: "agentManager.multiVersionProgress" status: "creating" | "done" @@ -307,6 +312,7 @@ export type AgentManagerOutMessage = | ErrorOutMessage | SessionAddedMessage | SessionForkedMessage + | SessionClosedMessage | MultiVersionProgressMessage | SetSessionModelMessage | SendInitialMessage diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 558961a6e2..b4467fb347 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -498,6 +498,9 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.agentManager.newWorktree", () => { agentManagerProvider.postMessage({ type: "action", action: "newWorktree" }) }), + vscode.commands.registerCommand("kilo-code.new.agentManager.quickWorktree", () => { + agentManagerProvider.postMessage({ type: "action", action: "quickWorktree" }) + }), vscode.commands.registerCommand("kilo-code.new.agentManager.openWorktree", () => { agentManagerProvider.postMessage({ type: "action", action: "openWorktree" }) }), diff --git a/packages/kilo-vscode/src/kilo-provider/command-completion.ts b/packages/kilo-vscode/src/kilo-provider/command-completion.ts new file mode 100644 index 0000000000..1b9ff0d66f --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/command-completion.ts @@ -0,0 +1,3 @@ +export function completesWithoutStatus(command: string): boolean { + return command === "local-review" || command === "local-review-uncommitted" +} diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index 19a3b2ab60..e978f40576 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -10,6 +10,7 @@ import type { CloudSessionData, EditorContext } from "../../services/cli-backend import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils" import type { MessageFile } from "../message-files" import { reviewMetadata, type ReviewMessageData } from "../../shared/review-comments" +import { completesWithoutStatus } from "../command-completion" const TIMEOUT = 30_000 @@ -232,6 +233,9 @@ export async function handleImportAndSend( { throwOnError: true }, ) }) + if (messageID && command && completesWithoutStatus(command)) { + ctx.postMessage({ type: "sessionCommandCompleted", messageID }) + } } catch (err) { console.error("[Kilo New] Failed to send message after cloud import:", err) ctx.postMessage({ diff --git a/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx b/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx new file mode 100644 index 0000000000..b530ab148b --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx @@ -0,0 +1,74 @@ +import { Window } from "happy-dom" +import type { QuestionRequest } from "../../webview-ui/src/types/messages" + +const window = new Window() +Object.assign(globalThis, { + window, + document: window.document, + Node: window.Node, + Element: window.Element, + HTMLElement: window.HTMLElement, + SVGElement: window.SVGElement, + requestAnimationFrame: () => 0, +}) + +const { Show, createSignal } = await import("solid-js") +const { render } = await import("solid-js/web") +const { SessionContext } = await import("../../webview-ui/src/context/session") +const { LanguageContext } = await import("../../webview-ui/src/context/language") +const { QuestionDock } = await import("../../webview-ui/src/components/chat/QuestionDock") + +const request: QuestionRequest = { + id: "question-1", + sessionID: "session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "Continue" }], + }, + ], +} +const [active, setActive] = createSignal(request) +const calls: Array<{ id: string; answers: string[][] }> = [] +const session = { + questionErrors: () => new Set(), + selectedAgent: () => "code", + selectAgent: () => {}, + replyToQuestion: (id: string, answers: string[][]) => { + calls.push({ id, answers }) + setActive(undefined) + }, + rejectQuestion: () => {}, + closeQuestion: () => {}, +} +const language = { + locale: () => "en", + setLocale: () => {}, + userOverride: () => "", + t: (key: string) => key, +} +const root = document.createElement("div") +document.body.append(root) +const dispose = render( + () => ( + + + {(item) => } + + + ), + root, +) + +const option = root.querySelector('[data-slot="question-option"]') +const submit = root.querySelector('[data-slot="question-footer-actions"] button') +if (!option || !submit) throw new Error("Question controls did not render") +option.click() +if (submit.disabled) throw new Error("Submit did not enable after selecting an answer") +submit.click() +if (calls.length !== 1 || calls[0]?.id !== request.id || calls[0]?.answers[0]?.[0] !== "Yes") { + throw new Error(`Unexpected question reply: ${JSON.stringify(calls)}`) +} +if (root.querySelector('[data-component="question-dock"]')) throw new Error("Question dock did not unmount") +dispose() diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 28f8cfb035..91ad20403b 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.9", + "version": "7.4.11", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 42a3cd8231..ff6a272555 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -20,6 +20,7 @@ const CSS_FILES = [ ] const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"), + path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"), path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"), @@ -269,6 +270,89 @@ describe("Agent Manager Model Picker", () => { }) }) +describe("Agent Manager Worktree Actions", () => { + it("opens the configuration dialog from the primary plus action", () => { + const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"), "utf-8") + const start = source.indexOf('
') + const end = source.indexOf("
", start) + const actions = source.slice(start, end) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + expect(actions).toContain("onClick={props.onNew}") + expect(actions).toContain('keybind={props.bindings.newWorktree ?? ""}') + expect(actions).toContain("") + expect(actions).toContain('props.t("sidebar.session.newWorktree.from")') + expect(actions).toContain('props.bindings.quickWorktree ?? ""') + expect(actions).not.toContain("onSelect={props.onNew}") + }) + + it("opens the configuration dialog from the new-worktree shortcut", () => { + const source = fs.readFileSync(TSX_FILE, "utf-8") + const start = source.indexOf('else if (msg.action === "newWorktree")') + const end = source.indexOf('else if (msg.action === "quickWorktree")', start) + const action = source.slice(start, end) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + expect(action).toContain("showNewWorktreeDialog()") + }) + + it("creates a worktree from the quick-worktree shortcut", () => { + const source = fs.readFileSync(TSX_FILE, "utf-8") + const start = source.indexOf('else if (msg.action === "quickWorktree")') + const end = source.indexOf('else if (msg.action === "openWorktree")', start) + const action = source.slice(start, end) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + expect(action).toContain("handleCreateWorktree()") + }) + + it("registers distinct dialog and quick-create worktree shortcuts", () => { + const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")) as { + contributes: { keybindings: { command: string; key?: string; mac?: string }[] } + } + const source = fs.readFileSync(TSX_FILE, "utf-8") + const dialog = manifest.contributes.keybindings.find( + (item) => item.command === "kilo-code.new.agentManager.newWorktree", + ) + const quick = manifest.contributes.keybindings.find( + (item) => item.command === "kilo-code.new.agentManager.quickWorktree", + ) + + expect(dialog).toMatchObject({ key: "ctrl+n", mac: "cmd+n" }) + expect(quick).toMatchObject({ key: "ctrl+shift+n", mac: "cmd+shift+n" }) + expect(source).toContain('newWorktree: isMac ? "⌘N" : "Ctrl+N"') + expect(source).toContain('quickWorktree: isMac ? "⌘⇧N" : "Ctrl+Shift+N"') + }) + + it("forwards the quick-worktree command to immediate creation", () => { + const source = fs.readFileSync(path.join(ROOT, "src/extension.ts"), "utf-8") + const start = source.indexOf('vscode.commands.registerCommand("kilo-code.new.agentManager.quickWorktree"') + const end = source.indexOf('vscode.commands.registerCommand("kilo-code.new.agentManager.openWorktree"', start) + const command = source.slice(start, end) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + expect(command).toContain('agentManagerProvider.postMessage({ type: "action", action: "quickWorktree" })') + }) + + it("shows the same mapping in the keyboard shortcuts dialog", () => { + const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/shortcuts.ts"), "utf-8") + + expect(source).toContain('t("agentManager.shortcuts.advancedWorktree"), binding: bindings.newWorktree') + expect(source).toContain('t("agentManager.shortcuts.newWorktree"), binding: bindings.quickWorktree') + }) + + it("does not attribute the new-worktree shortcut to session promotion", () => { + const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"), "utf-8") + + expect(source).toContain(' unknown } panel: { + postMessage: (message: unknown) => void sessions: { getSessionDirectories: () => ReadonlyMap clearSessionDirectory: (id: string) => void @@ -24,6 +25,7 @@ function createManager(options?: { dir?: string; panelDir?: string; state?: bool const aborted: string[][] = [] const cleared: string[] = [] const removed: string[] = [] + const messages: unknown[] = [] const events: string[] = [] const client = { backgroundProcess: { @@ -44,6 +46,7 @@ function createManager(options?: { dir?: string; panelDir?: string; state?: bool const manager = Object.create(AgentManagerProvider.prototype) as Manager manager.connectionService = { getClient: () => client } manager.panel = { + postMessage: (message) => messages.push(message), sessions: { getSessionDirectories: () => new Map(options?.panelDir ? [["s1", options.panelDir]] : []), clearSessionDirectory: (id) => cleared.push(id), @@ -59,12 +62,12 @@ function createManager(options?: { dir?: string; panelDir?: string; state?: bool manager.pushState = mock(() => undefined) manager.log = mock(() => undefined) - return { manager, stopped, aborted, cleared, removed, events } + return { manager, stopped, aborted, cleared, removed, messages, events } } describe("AgentManagerProvider closeSession", () => { it("aborts the agent before stopping processes and removing its tab", async () => { - const { manager, stopped, aborted, cleared, removed, events } = createManager({ dir: "/repo/worktree" }) + const { manager, stopped, aborted, cleared, removed, messages, events } = createManager({ dir: "/repo/worktree" }) await manager.onCloseSession("s1") @@ -73,6 +76,7 @@ describe("AgentManagerProvider closeSession", () => { expect(events).toEqual(["abort", "processes", "remove"]) expect(removed).toEqual(["s1"]) expect(cleared).toEqual(["s1"]) + expect(messages).toEqual([]) expect(manager.panelSessions.has("s1")).toBe(false) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts index 0403894b5c..3c3dfdac08 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts @@ -42,7 +42,9 @@ describe("AgentManagerOrchestrationBridge", () => { state?: (state: "connecting" | "connected" | "disconnected" | "error") => void } = {} const status = { failList: "", failReply: false } + const managed = new Set(["ses_target"]) const promptAsync = mock(async () => ({ data: undefined })) + const close = mock(async () => undefined) const client = { session: { get: mock(async () => ({ @@ -95,6 +97,8 @@ describe("AgentManagerOrchestrationBridge", () => { state: () => state, stats: async () => ({ worktrees: [] }), prs: () => new Map(), + managed: (id) => managed.has(id), + close, log: () => undefined, }) const request = (value: AgentManagerRequest, directory = root) => @@ -102,7 +106,7 @@ describe("AgentManagerOrchestrationBridge", () => { { id: `event-${value.id}`, type: "kilocode.agent_manager.requested", properties: value } as SSEPayload, directory, ) - return { bridge, client, handlers, lists, promptAsync, rejections, replies, request, status } + return { bridge, client, close, handlers, lists, managed, promptAsync, rejections, replies, request, status } } const request: AgentManagerRequest = { @@ -148,6 +152,85 @@ describe("AgentManagerOrchestrationBridge", () => { test.bridge.dispose() }) + it("stops a managed session through the same close operation as the UI", async () => { + const test = harness() + test.status.failReply = true + const stop: AgentManagerRequest = { + id: "amr_stop", + sessionID: "ses_caller", + operation: "stop", + targetSessionID: "ses_target", + } + + test.request(stop) + await waitFor(() => test.replies.length === 1) + test.status.failReply = false + test.request(stop) + await waitFor(() => test.replies.length === 2) + + expect(test.close).toHaveBeenCalledTimes(1) + expect(test.close).toHaveBeenCalledWith("ses_target") + expect(test.replies).toEqual([ + { + requestID: "amr_stop", + directory: root, + result: { operation: "stop", sessionID: "ses_target", stopped: true }, + }, + { + requestID: "amr_stop", + directory: root, + result: { operation: "stop", sessionID: "ses_target", stopped: true }, + }, + ]) + test.bridge.dispose() + }) + + it("stops a live panel session before it is persisted", async () => { + const test = harness() + test.managed.add("ses_live") + + test.request({ + id: "amr_stop_live", + sessionID: "ses_caller", + operation: "stop", + targetSessionID: "ses_live", + }) + await waitFor(() => test.replies.length === 1) + + expect(state.getSession("ses_live")).toBeUndefined() + expect(test.close).toHaveBeenCalledWith("ses_live") + expect(test.replies[0]).toEqual({ + requestID: "amr_stop_live", + directory: root, + result: { operation: "stop", sessionID: "ses_live", stopped: true }, + }) + test.bridge.dispose() + }) + + it("rejects stopping a session not managed by the current workspace", async () => { + const test = harness() + test.request({ + id: "amr_stop_unknown", + sessionID: "ses_caller", + operation: "stop", + targetSessionID: "ses_unknown", + }) + await waitFor(() => test.rejections.length === 1) + + expect(test.close).not.toHaveBeenCalled() + expect(test.rejections).toEqual([ + { + requestID: "amr_stop_unknown", + directory: root, + error: { + code: "unknown_session", + message: "The session is not managed by this Agent Manager workspace", + }, + }, + ]) + test.bridge.dispose() + }) + it("rejects request origins outside the current Agent Manager workspace", async () => { const test = harness() diff --git a/packages/kilo-vscode/tests/unit/command-completion.test.ts b/packages/kilo-vscode/tests/unit/command-completion.test.ts new file mode 100644 index 0000000000..9c1373e917 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/command-completion.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "bun:test" +import { completesWithoutStatus } from "../../src/kilo-provider/command-completion" + +describe("completesWithoutStatus", () => { + it("matches only deprecated static review aliases", () => { + expect(completesWithoutStatus("local-review")).toBe(true) + expect(completesWithoutStatus("local-review-uncommitted")).toBe(true) + expect(completesWithoutStatus("review")).toBe(false) + expect(completesWithoutStatus("init")).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 1e2553d842..8cce494525 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -21,6 +21,7 @@ const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.t const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts") const PROMPT_FILE = path.join(ROOT, "webview-ui/src/components/chat/PromptInput.tsx") const KILOPROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") +const CLOUD_SESSION_FILE = path.join(ROOT, "src/kilo-provider/handlers/cloud-session.ts") const CONNECTION_SERVICE_FILE = path.join(ROOT, "src/services/cli-backend/connection-service.ts") function readFile(filePath: string): string { @@ -79,6 +80,32 @@ describe("sendCommand dismisses pending tool requests", () => { }) }) +describe("static command completion contract", () => { + const source = readFile(SESSION_FILE) + + it("finishes only the acknowledged command submission", () => { + const body = extractFunctionBody(source, "handleCommandCompletion") + expect(body).toMatch(/message\.type === "sessionCommandCompleted"\) finishSubmission\(message\.messageID\)/) + }) + + it("acknowledges aliases after direct and cloud command confirmation", () => { + const provider = readFile(KILOPROVIDER_FILE) + const cloud = readFile(CLOUD_SESSION_FILE) + expect(provider).toMatch( + /await runWithMessageConfirmation[\s\S]*?if \(messageID && completesWithoutStatus\(command\)\)[\s\S]*?sessionCommandCompleted/, + ) + expect(cloud).toMatch( + /await run\(messageID, "Cloud import send"[\s\S]*?if \(messageID && command && completesWithoutStatus\(command\)\)[\s\S]*?sessionCommandCompleted/, + ) + }) + + it("does not clear every session submission or active abort", () => { + const body = extractFunctionBody(source, "handleCommandCompletion") + expect(body).not.toContain("confirmSubmissions") + expect(body).not.toContain("aborts.clear") + }) +}) + describe("ChatView prompt-block contract", () => { const source = readFile(CHATVIEW_FILE) diff --git a/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts b/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts new file mode 100644 index 0000000000..cd9c5da3dd --- /dev/null +++ b/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test" +import { unlinkSync } from "node:fs" +import path from "node:path" +import { build } from "esbuild" +import { solidPlugin } from "esbuild-plugin-solid" + +const ROOT = path.resolve(import.meta.dir, "../..") +const WEBVIEW = path.join(ROOT, "webview-ui") +const FIXTURE = path.join(ROOT, "tests/fixtures/question-dock-disposal.tsx") + +describe("QuestionDock disposal", () => { + it("does not read a stale callback-form Show accessor", async () => { + const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW)) + const aliases: Record = { + "solid-js": path.join(solid, "dist/solid.js"), + "solid-js/web": path.join(solid, "web/dist/web.js"), + "solid-js/store": path.join(solid, "store/dist/store.js"), + } + const dedupe = { + name: "solid-dedupe", + setup(ctx: Parameters[0]["plugins"]>[number]["setup"]>[0]) { + ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] })) + }, + } + const result = await build({ + entryPoints: [FIXTURE], + bundle: true, + conditions: ["browser"], + external: ["happy-dom"], + format: "esm", + logLevel: "silent", + platform: "node", + plugins: [dedupe, solidPlugin()], + target: "es2022", + write: false, + }) + const file = path.join(ROOT, `.question-dock-disposal-${crypto.randomUUID()}.mjs`) + await Bun.write(file, result.outputFiles[0]!.contents) + const child = Bun.spawnSync(["bun", file], { cwd: WEBVIEW, stdout: "pipe", stderr: "pipe" }) + unlinkSync(file) + + const output = child.stdout.toString() + child.stderr.toString() + expect(child.exitCode, output).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/tool-default-open.test.ts b/packages/kilo-vscode/tests/unit/tool-default-open.test.ts new file mode 100644 index 0000000000..c36e66f81a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/tool-default-open.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test" +import type { Part, ToolPart } from "@kilocode/sdk/v2" +import { toolDefaultOpen } from "../../webview-ui/src/components/chat/tool-default-open" + +function tool(name: string) { + return { type: "tool", tool: name } as ToolPart +} + +describe("toolDefaultOpen", () => { + it.each(["bash", "background_process"])("uses the terminal preference for %s", (name) => { + expect(toolDefaultOpen(tool(name), false, true)).toBe(false) + expect(toolDefaultOpen(tool(name), true, false)).toBe(true) + }) + + it.each(["edit", "write", "apply_patch"])("uses the code edit preference for %s", (name) => { + expect(toolDefaultOpen(tool(name), true, false)).toBe(false) + expect(toolDefaultOpen(tool(name), false, true)).toBe(true) + }) + + it("leaves unrelated parts unchanged", () => { + expect(toolDefaultOpen(tool("read"), true, true)).toBeUndefined() + expect(toolDefaultOpen({ type: "text" } as Part, true, true)).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 4f5defffd8..a9f607b156 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -56,7 +56,6 @@ import { ThemeProvider } from "@kilocode/kilo-ui/theme" import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog" import { Dialog } from "@kilocode/kilo-ui/dialog" import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" -import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" @@ -92,7 +91,6 @@ import { NewWorktreeDialog } from "./NewWorktreeDialog" import { DataBridge, MermaidDownloadBridge } from "../src/App" import { LanguageBridge } from "../src/context/language-bridge" import { useLanguage } from "../src/context/language" -import { formatRelativeDate } from "../src/utils/date" import { createTabFocus } from "../src/utils/tab-navigation" import { canOpenRootSession, @@ -137,6 +135,7 @@ import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search" import { WorktreeSectionActions } from "./WorktreeSectionActions" import { BranchSelect } from "../src/components/shared/BranchSelect" import { WorktreeItem } from "./WorktreeItem" +import { UnassignedSessionsSection } from "./UnassignedSessionsSection" import SectionHeader from "./SectionHeader" import { randomColor } from "./section-colors" import { createNewTaskDrafts } from "./new-task-drafts" @@ -204,7 +203,7 @@ const defaultBindings: Record = { newTab: isMac ? "⌘T" : "Ctrl+T", closeTab: isMac ? "⌘W" : "Ctrl+W", newWorktree: isMac ? "⌘N" : "Ctrl+N", - advancedWorktree: isMac ? "⌘⇧N" : "Ctrl+Shift+N", + quickWorktree: isMac ? "⌘⇧N" : "Ctrl+Shift+N", closeWorktree: isMac ? "⌘⇧W" : "Ctrl+Shift+W", openWorktree: isMac ? "⌘⇧O" : "Ctrl+Shift+O", openPR: isMac ? "⌘⇧R" : "Ctrl+Shift+R", @@ -256,6 +255,11 @@ const AgentManagerContent: Component = () => { setLocalSessionIDs((prev) => (prev.includes(sid) ? prev.filter((id) => id !== sid) : prev)) const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH) const [sessionsCollapsed, setSessionsCollapsed] = createSignal(true) + const toggleSessions = () => { + const collapsed = !sessionsCollapsed() + setSessionsCollapsed(collapsed) + vscode.postMessage({ type: "agentManager.setSessionsCollapsed", collapsed }) + } const sidebar = createSidebarCollapse(vscode) const sidebarCollapsed = sidebar.collapsed const expandSidebar = sidebar.expand @@ -925,15 +929,17 @@ const AgentManagerContent: Component = () => { const scrollIntoView = (el: HTMLElement) => el.scrollIntoView({ block: "nearest", behavior: "smooth" }) + const selectUnassigned = (id: string) => { + saveTabMemory() + setSelection(null) + setReviewActive(false) + session.selectSession(id) + } + const focusSidebarItem = (item: { type: string; id: string }) => { if (item.type === "local") selectLocal() else if (item.type === "wt") selectWorktree(item.id) - else { - saveTabMemory() - setSelection(null) - setReviewActive(false) - session.selectSession(item.id) - } + else selectUnassigned(item.id) const el = document.querySelector(`[data-sidebar-id="${item.id}"]`) if (el instanceof HTMLElement) scrollIntoView(el) } @@ -1100,11 +1106,12 @@ const AgentManagerContent: Component = () => { } else setSidePanel((prev) => (prev === "diff" ? null : "diff")) } else if (msg.action === "newTab") handleNewTabForCurrentSelection() else if (msg.action === "closeTab") closeActiveTab() - else if (msg.action === "newWorktree") handleNewWorktreeOrPromote() + else if (msg.action === "newWorktree") showNewWorktreeDialog() + else if (msg.action === "quickWorktree") handleCreateWorktree() else if (msg.action === "openWorktree") openWorktreeDirectory() else if (msg.action === "openPR") openSelectedPR() else if (msg.action === "runScript") runSelected() - else if (msg.action === "advancedWorktree") showAdvancedWorktreeDialog() + else if (msg.action === "advancedWorktree") showNewWorktreeDialog() else if (msg.action === "closeWorktree") closeSelectedWorktree() else if (msg.action === "showShortcuts") handleShowKeyboardShortcuts() else if (msg.action === "focusInput") window.dispatchEvent(new Event("focusPrompt")) @@ -1215,8 +1222,8 @@ const AgentManagerContent: Component = () => { // Mark sessions loaded as soon as the session context receives data (even if empty) const unsubSessions = vscode.onMessage((msg) => { if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true) + if (msg.type === "agentManager.sessionClosed") handleCloseTab(msg.sessionId, false) }) - const unsubRun = vscode.onMessage((msg) => { if (msg.type !== "agentManager.runStatus") return const ev = msg as RunStatus @@ -1844,8 +1851,7 @@ const AgentManagerContent: Component = () => { } const createWorktree = metrics.click("new_worktree", "worktrees", handleCreateWorktree) - // Advanced worktree dialog — opens a full dialog with prompt, versions, model, mode - const showAdvancedWorktreeDialog = () => { + const showNewWorktreeDialog = () => { if (!loaded()) return expandSidebar() dialog.show(() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />) @@ -1934,8 +1940,7 @@ const AgentManagerContent: Component = () => { confirmDeleteWorktree(worktreeId) } - const handlePromote = (sessionId: string, e: MouseEvent) => { - e.stopPropagation() + const promoteSession = (sessionId: string) => { if (!loaded()) return metrics.track("promote_session", "unassigned_session") vscode.postMessage({ type: "agentManager.promoteSession", sessionId }) @@ -1953,6 +1958,11 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.openLocally", sessionId: sid }) } + const openUnassigned = (id: string) => { + metrics.track("open_session_locally", "unassigned_session_menu") + openLocally(id) + } + const handleAddSession = () => { const sel = selection() expandSidebar() @@ -1969,7 +1979,7 @@ const AgentManagerContent: Component = () => { if (!sel || sel === LOCAL) return vscode.postMessage(msg) vscode.postMessage({ ...msg, worktreeId: sel }) } - const handleCloseTab = (sessionId: string) => { + const handleCloseTab = (sessionId: string, notify = true) => { freezeTabs() const pending = isPending(sessionId) const isActive = pending ? sessionId === activePendingId() : session.currentSessionID() === sessionId @@ -1999,7 +2009,7 @@ const AgentManagerContent: Component = () => { if (session.isSubmitting(sessionId) || isPendingSend(sessionId)) discardPendingDraft(sessionId) queueMicrotask(() => deletePendingDraft(sessionId)) } - vscode.postMessage({ type: "agentManager.closeSession", sessionId }) + if (notify) vscode.postMessage({ type: "agentManager.closeSession", sessionId }) tabFocus.restore() } @@ -2174,18 +2184,6 @@ const AgentManagerContent: Component = () => { } } - // Cmd+N: if an unassigned session is selected, promote it; otherwise create a new worktree - const handleNewWorktreeOrPromote = () => { - if (!loaded()) return - const sel = selection() - const sid = session.currentSessionID() - if (sel === null && sid && !worktreeSessionIds().has(sid)) { - vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid }) - return - } - handleCreateWorktree() - } - // Close the currently selected worktree with a confirmation dialog const closeSelectedWorktree = () => { const sel = selection() @@ -2312,7 +2310,7 @@ const AgentManagerContent: Component = () => { onRef={(value) => (sidebarSearchMenu = value)} onSelect={focusSidebarSearchItem} onCreate={createWorktree} - onAdvanced={showAdvancedWorktreeDialog} + onNew={showNewWorktreeDialog} onSection={newSection} onShortcuts={metrics.click("keyboard_shortcuts", "worktrees_header", handleShowKeyboardShortcuts)} onSetup={setupScript} @@ -2549,7 +2547,7 @@ const AgentManagerContent: Component = () => { ) })()} - @@ -2559,102 +2557,16 @@ const AgentManagerContent: Component = () => {
- {/* SESSIONS section (unassigned) — collapsible */} -
- - -
- -
-
-
-
-
-
-
-
-
-
-
-
-
- } - > - - {(s) => ( - - - - - - - handlePromote(s.id, new MouseEvent("click"))}> - - {t("agentManager.session.openInWorktree")} - - - openLocally(s.id), - )} - > - - {t("agentManager.session.openLocally")} - - - - - )} - - -
- -
+ (selection() === null ? session.currentSessionID() : undefined)} + onToggle={toggleSessions} + onSelect={selectUnassigned} + onPromote={promoteSession} + onOpen={openUnassigned} + />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx new file mode 100644 index 0000000000..606d579eb2 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx @@ -0,0 +1,104 @@ +import { For, Show, type Accessor, type Component } from "solid-js" +import { ContextMenu } from "@kilocode/kilo-ui/context-menu" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import type { SessionInfo } from "../src/types/messages" +import { useLanguage } from "../src/context/language" +import { formatRelativeDate } from "../src/utils/date" + +interface Props { + sessions: Accessor + loaded: Accessor + collapsed: Accessor + active: Accessor + onToggle: () => void + onSelect: (id: string) => void + onPromote: (id: string) => void + onOpen: (id: string) => void +} + +export const UnassignedSessionsSection: Component = (props) => { + const { t } = useLanguage() + + const promote = (id: string, event: MouseEvent) => { + event.stopPropagation() + props.onPromote(id) + } + + return ( +
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ } + > + + {(session) => ( + + + + + + + props.onPromote(session.id)}> + + {t("agentManager.session.openInWorktree")} + + props.onOpen(session.id)}> + + {t("agentManager.session.openLocally")} + + + + + )} + + +
+ +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx index 0980bb060f..7517e3f390 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx @@ -22,7 +22,7 @@ interface WorktreeSectionActionsProps { onRef: (ref: SidebarSearchMenuRef) => void onSelect: (item: SidebarSearchItem) => void onCreate: () => void - onAdvanced: () => void + onNew: () => void onSection: () => void onShortcuts: () => void onSetup: () => void @@ -48,14 +48,19 @@ export const WorktreeSectionActions: Component = (p />
- + + + = (p - {parseBindingTokens(props.bindings.newWorktree ?? "").map((token) => ( - {token} - ))} - - - - - {props.t("agentManager.dialog.configureWorktree")} - - {parseBindingTokens(props.bindings.advancedWorktree ?? "").map((token) => ( + {parseBindingTokens(props.bindings.quickWorktree ?? "").map((token) => ( {token} ))} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index e4e113265c..26ccbd249b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -78,9 +78,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "العنصر السابق", "agentManager.shortcuts.nextItem": "العنصر التالي", "agentManager.shortcuts.newWorktree": "Worktree جديد", + "agentManager.shortcuts.advancedWorktree": "تكوين worktree جديد", "agentManager.shortcuts.openWorktree": "فتح Worktree", "agentManager.shortcuts.openPR": "فتح طلب السحب", - "agentManager.shortcuts.advancedWorktree": "تكوين worktree جديد", "agentManager.shortcuts.deleteWorktree": "حذف Worktree", "agentManager.shortcuts.previousTab": "علامة التبويب السابقة", "agentManager.shortcuts.nextTab": "علامة التبويب التالية", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 4e35e25555..c94912316d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -80,9 +80,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Item anterior", "agentManager.shortcuts.nextItem": "Próximo item", "agentManager.shortcuts.newWorktree": "Novo Worktree", + "agentManager.shortcuts.advancedWorktree": "Configurar novo worktree", "agentManager.shortcuts.openWorktree": "Abrir Worktree", "agentManager.shortcuts.openPR": "Abrir PR", - "agentManager.shortcuts.advancedWorktree": "Configurar novo worktree", "agentManager.shortcuts.deleteWorktree": "Excluir Worktree", "agentManager.shortcuts.previousTab": "Aba anterior", "agentManager.shortcuts.nextTab": "Próxima aba", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 7b0a985542..918c3d7136 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -80,9 +80,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Prethodni element", "agentManager.shortcuts.nextItem": "Sljedeći element", "agentManager.shortcuts.newWorktree": "Novi Worktree", + "agentManager.shortcuts.advancedWorktree": "Konfiguriši novi worktree", "agentManager.shortcuts.openWorktree": "Otvori worktree", "agentManager.shortcuts.openPR": "Otvori PR", - "agentManager.shortcuts.advancedWorktree": "Konfiguriši novi worktree", "agentManager.shortcuts.deleteWorktree": "Obriši Worktree", "agentManager.shortcuts.previousTab": "Prethodna kartica", "agentManager.shortcuts.nextTab": "Sljedeća kartica", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index dcaf595741..5ab0555293 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -81,9 +81,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Forrige element", "agentManager.shortcuts.nextItem": "Næste element", "agentManager.shortcuts.newWorktree": "Nyt Worktree", + "agentManager.shortcuts.advancedWorktree": "Konfigurer nyt worktree", "agentManager.shortcuts.openWorktree": "Åbn Worktree", "agentManager.shortcuts.openPR": "Åbn PR", - "agentManager.shortcuts.advancedWorktree": "Konfigurer nyt worktree", "agentManager.shortcuts.deleteWorktree": "Slet Worktree", "agentManager.shortcuts.previousTab": "Forrige fane", "agentManager.shortcuts.nextTab": "Næste fane", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index defb5086b9..f667d575d5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -81,9 +81,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Vorheriges Element", "agentManager.shortcuts.nextItem": "Nächstes Element", "agentManager.shortcuts.newWorktree": "Neuer Worktree", + "agentManager.shortcuts.advancedWorktree": "Neuen Worktree konfigurieren", "agentManager.shortcuts.openWorktree": "Worktree öffnen", "agentManager.shortcuts.openPR": "Pull Request öffnen", - "agentManager.shortcuts.advancedWorktree": "Neuen Worktree konfigurieren", "agentManager.shortcuts.deleteWorktree": "Worktree löschen", "agentManager.shortcuts.previousTab": "Vorheriger Tab", "agentManager.shortcuts.nextTab": "Nächster Tab", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index f86650926d..930d95751f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -84,9 +84,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Previous item", "agentManager.shortcuts.nextItem": "Next item", "agentManager.shortcuts.newWorktree": "New worktree", + "agentManager.shortcuts.advancedWorktree": "Configure new worktree", "agentManager.shortcuts.openWorktree": "Open worktree", "agentManager.shortcuts.openPR": "Open pull request", - "agentManager.shortcuts.advancedWorktree": "Configure new worktree", "agentManager.shortcuts.deleteWorktree": "Delete worktree", "agentManager.shortcuts.previousTab": "Previous tab", "agentManager.shortcuts.nextTab": "Next tab", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index db596cb8a8..83a7591bd0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -80,9 +80,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Elemento anterior", "agentManager.shortcuts.nextItem": "Siguiente elemento", "agentManager.shortcuts.newWorktree": "Nuevo Worktree", + "agentManager.shortcuts.advancedWorktree": "Configurar nuevo worktree", "agentManager.shortcuts.openWorktree": "Abrir Worktree", "agentManager.shortcuts.openPR": "Abrir pull request", - "agentManager.shortcuts.advancedWorktree": "Configurar nuevo worktree", "agentManager.shortcuts.deleteWorktree": "Eliminar Worktree", "agentManager.shortcuts.previousTab": "Pestaña anterior", "agentManager.shortcuts.nextTab": "Siguiente pestaña", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 2a2376bf54..6874c5057d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -80,9 +80,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Élément précédent", "agentManager.shortcuts.nextItem": "Élément suivant", "agentManager.shortcuts.newWorktree": "Nouveau Worktree", + "agentManager.shortcuts.advancedWorktree": "Configurer un nouveau worktree", "agentManager.shortcuts.openWorktree": "Ouvrir le worktree", "agentManager.shortcuts.openPR": "Ouvrir la pull request", - "agentManager.shortcuts.advancedWorktree": "Configurer un nouveau worktree", "agentManager.shortcuts.deleteWorktree": "Supprimer le Worktree", "agentManager.shortcuts.previousTab": "Onglet précédent", "agentManager.shortcuts.nextTab": "Onglet suivant", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 9012f4f5d3..900596bfd4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -85,9 +85,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Elemento precedente", "agentManager.shortcuts.nextItem": "Elemento successivo", "agentManager.shortcuts.newWorktree": "Nuovo worktree", + "agentManager.shortcuts.advancedWorktree": "Configura nuovo worktree", "agentManager.shortcuts.openWorktree": "Apri worktree", "agentManager.shortcuts.openPR": "Apri pull request", - "agentManager.shortcuts.advancedWorktree": "Configura nuovo worktree", "agentManager.shortcuts.deleteWorktree": "Elimina worktree", "agentManager.shortcuts.previousTab": "Scheda precedente", "agentManager.shortcuts.nextTab": "Scheda successiva", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 341b09b8b4..59bc23b477 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -80,9 +80,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "前の項目", "agentManager.shortcuts.nextItem": "次の項目", "agentManager.shortcuts.newWorktree": "新しいWorktree", + "agentManager.shortcuts.advancedWorktree": "新規 worktree の構成", "agentManager.shortcuts.openWorktree": "Worktreeを開く", "agentManager.shortcuts.openPR": "PRを開く", - "agentManager.shortcuts.advancedWorktree": "新規 worktree の構成", "agentManager.shortcuts.deleteWorktree": "Worktreeを削除", "agentManager.shortcuts.previousTab": "前のタブ", "agentManager.shortcuts.nextTab": "次のタブ", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index f1881ed9af..6bc68261a5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -79,9 +79,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "이전 항목", "agentManager.shortcuts.nextItem": "다음 항목", "agentManager.shortcuts.newWorktree": "새 Worktree", + "agentManager.shortcuts.advancedWorktree": "새 worktree 구성", "agentManager.shortcuts.openWorktree": "Worktree 열기", "agentManager.shortcuts.openPR": "PR 열기", - "agentManager.shortcuts.advancedWorktree": "새 worktree 구성", "agentManager.shortcuts.deleteWorktree": "Worktree 삭제", "agentManager.shortcuts.previousTab": "이전 탭", "agentManager.shortcuts.nextTab": "다음 탭", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 771b3d8b4a..ed7b6c265f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -84,9 +84,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Vorig item", "agentManager.shortcuts.nextItem": "Volgend item", "agentManager.shortcuts.newWorktree": "Nieuwe worktree", + "agentManager.shortcuts.advancedWorktree": "Nieuwe worktree configureren", "agentManager.shortcuts.openWorktree": "Worktree openen", "agentManager.shortcuts.openPR": "Pull request openen", - "agentManager.shortcuts.advancedWorktree": "Nieuwe worktree configureren", "agentManager.shortcuts.deleteWorktree": "Worktree verwijderen", "agentManager.shortcuts.previousTab": "Vorig tabblad", "agentManager.shortcuts.nextTab": "Volgend tabblad", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 7245dde8c1..e52ab1cd76 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -79,9 +79,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Forrige element", "agentManager.shortcuts.nextItem": "Neste element", "agentManager.shortcuts.newWorktree": "Nytt Worktree", + "agentManager.shortcuts.advancedWorktree": "Konfigurer nytt worktree", "agentManager.shortcuts.openWorktree": "Åpne Worktree", "agentManager.shortcuts.openPR": "Åpne pull request", - "agentManager.shortcuts.advancedWorktree": "Konfigurer nytt worktree", "agentManager.shortcuts.deleteWorktree": "Slett Worktree", "agentManager.shortcuts.previousTab": "Forrige fane", "agentManager.shortcuts.nextTab": "Neste fane", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 8882f527ee..712b88137e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -80,9 +80,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Poprzedni element", "agentManager.shortcuts.nextItem": "Następny element", "agentManager.shortcuts.newWorktree": "Nowy Worktree", + "agentManager.shortcuts.advancedWorktree": "Skonfiguruj nowe worktree", "agentManager.shortcuts.openWorktree": "Otwórz Worktree", "agentManager.shortcuts.openPR": "Otwórz pull request", - "agentManager.shortcuts.advancedWorktree": "Skonfiguruj nowe worktree", "agentManager.shortcuts.deleteWorktree": "Usuń Worktree", "agentManager.shortcuts.previousTab": "Poprzednia karta", "agentManager.shortcuts.nextTab": "Następna karta", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 32de0ffe93..28025b436f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -80,9 +80,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Предыдущий элемент", "agentManager.shortcuts.nextItem": "Следующий элемент", "agentManager.shortcuts.newWorktree": "Новый Worktree", + "agentManager.shortcuts.advancedWorktree": "Настроить новое worktree", "agentManager.shortcuts.openWorktree": "Открыть Worktree", "agentManager.shortcuts.openPR": "Открыть PR", - "agentManager.shortcuts.advancedWorktree": "Настроить новое worktree", "agentManager.shortcuts.deleteWorktree": "Удалить Worktree", "agentManager.shortcuts.previousTab": "Предыдущая вкладка", "agentManager.shortcuts.nextTab": "Следующая вкладка", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index d95edac43a..a647391fde 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -76,9 +76,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "รายการก่อนหน้า", "agentManager.shortcuts.nextItem": "รายการถัดไป", "agentManager.shortcuts.newWorktree": "Worktree ใหม่", + "agentManager.shortcuts.advancedWorktree": "กำหนดค่า worktree ใหม่", "agentManager.shortcuts.openWorktree": "เปิด Worktree", "agentManager.shortcuts.openPR": "เปิด Pull Request", - "agentManager.shortcuts.advancedWorktree": "กำหนดค่า worktree ใหม่", "agentManager.shortcuts.deleteWorktree": "ลบ Worktree", "agentManager.shortcuts.previousTab": "แท็บก่อนหน้า", "agentManager.shortcuts.nextTab": "แท็บถัดไป", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index b4d6751d52..0b8406748e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -85,9 +85,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Önceki öğe", "agentManager.shortcuts.nextItem": "Sonraki öğe", "agentManager.shortcuts.newWorktree": "Yeni worktree", + "agentManager.shortcuts.advancedWorktree": "Yeni worktree yapılandır", "agentManager.shortcuts.openWorktree": "Worktree aç", "agentManager.shortcuts.openPR": "Pull request'i aç", - "agentManager.shortcuts.advancedWorktree": "Yeni worktree yapılandır", "agentManager.shortcuts.deleteWorktree": "Worktree'yi sil", "agentManager.shortcuts.previousTab": "Önceki sekme", "agentManager.shortcuts.nextTab": "Sonraki sekme", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index ba61ee1e13..505e0b4135 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -86,9 +86,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "Попередній елемент", "agentManager.shortcuts.nextItem": "Наступний елемент", "agentManager.shortcuts.newWorktree": "Нове робоче дерево", + "agentManager.shortcuts.advancedWorktree": "Налаштувати нове worktree", "agentManager.shortcuts.openWorktree": "Відкрити робоче дерево", "agentManager.shortcuts.openPR": "Відкрити PR", - "agentManager.shortcuts.advancedWorktree": "Налаштувати нове worktree", "agentManager.shortcuts.deleteWorktree": "Видалити робоче дерево", "agentManager.shortcuts.previousTab": "Попередня вкладка", "agentManager.shortcuts.nextTab": "Наступна вкладка", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 8e53f3a6ba..dda46cc2b3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -76,9 +76,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "上一项", "agentManager.shortcuts.nextItem": "下一项", "agentManager.shortcuts.newWorktree": "新建 Worktree", + "agentManager.shortcuts.advancedWorktree": "配置新 worktree", "agentManager.shortcuts.openWorktree": "打开 Worktree", "agentManager.shortcuts.openPR": "打开 PR", - "agentManager.shortcuts.advancedWorktree": "配置新 worktree", "agentManager.shortcuts.deleteWorktree": "删除 Worktree", "agentManager.shortcuts.previousTab": "上一个标签页", "agentManager.shortcuts.nextTab": "下一个标签页", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index d5a2e63db7..27b2375b84 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -76,9 +76,9 @@ export const dict = { "agentManager.shortcuts.previousItem": "上一個項目", "agentManager.shortcuts.nextItem": "下一個項目", "agentManager.shortcuts.newWorktree": "新建 Worktree", + "agentManager.shortcuts.advancedWorktree": "配置新 worktree", "agentManager.shortcuts.openWorktree": "開啟 Worktree", "agentManager.shortcuts.openPR": "開啟 PR", - "agentManager.shortcuts.advancedWorktree": "配置新 worktree", "agentManager.shortcuts.deleteWorktree": "刪除 Worktree", "agentManager.shortcuts.previousTab": "上一個分頁", "agentManager.shortcuts.nextTab": "下一個分頁", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/shortcuts.ts b/packages/kilo-vscode/webview-ui/agent-manager/shortcuts.ts index b1a198898d..6dca13dc22 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/shortcuts.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/shortcuts.ts @@ -32,8 +32,8 @@ export function buildShortcutCategories( shortcuts: [ { label: t("agentManager.shortcuts.previousItem"), binding: bindings.previousSession ?? "" }, { label: t("agentManager.shortcuts.nextItem"), binding: bindings.nextSession ?? "" }, - { label: t("agentManager.shortcuts.newWorktree"), binding: bindings.newWorktree ?? "" }, - { label: t("agentManager.shortcuts.advancedWorktree"), binding: bindings.advancedWorktree ?? "" }, + { label: t("agentManager.shortcuts.advancedWorktree"), binding: bindings.newWorktree ?? "" }, + { label: t("agentManager.shortcuts.newWorktree"), binding: bindings.quickWorktree ?? "" }, { label: t("agentManager.shortcuts.deleteWorktree"), binding: bindings.closeWorktree ?? "" }, { label: t("agentManager.shortcuts.openWorktree"), binding: bindings.openWorktree ?? "" }, { label: t("agentManager.shortcuts.openPR"), binding: bindings.openPR ?? "" }, diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 57f9c868c6..f2045ddf3d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -33,14 +33,7 @@ import type { Part as TimelinePart } from "../../types/messages" import type { TimelineHighlight } from "../../utils/timeline/highlight" import { QuestionDock } from "./QuestionDock" import { SuggestBar } from "./SuggestBar" - -const EDIT_TOOLS = new Set(["edit", "write", "apply_patch"]) - -function editOpen(part: SDKPart, open: boolean) { - if (part.type !== "tool") return undefined - const tool = (part as unknown as ToolPart).tool - return EDIT_TOOLS.has(tool) ? open : undefined -} +import { toolDefaultOpen } from "./tool-default-open" /** Extract plan path from a completed plan_exit tool part. */ function planExitInfo(part: SDKPart): { plan: string } | undefined { @@ -296,7 +289,7 @@ export const AssistantMessage: Component = (props) => { part={part} message={props.message as SDKMessage} showAssistantCopyPartID={props.showAssistantCopyPartID} - defaultOpen={editOpen(part, edit())} + defaultOpen={toolDefaultOpen(part, open(), edit())} forceOpen={forceOpen()} forceOpenFile={forceOpen() ? props.forceOpenFile : undefined} reasoningAutoCollapse={display.reasoningAutoCollapse()} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 5f727650de..736c6355a0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -26,6 +26,7 @@ import { isEnterKeyCommitNotIme } from "../../utils/ime-enter" export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { const session = useSession() const language = useLanguage() + const id = props.request.id const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -57,8 +58,8 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => // Chat search indexes only the mounted page's options, and there's no // other signal exposing which page that is — publish it here so search // stays in sync as the user navigates instead of always assuming page 0. - createEffect(() => setActiveQuestionTab(props.request.id, store.tab)) - onCleanup(() => clearActiveQuestionTab(props.request.id)) + createEffect(() => setActiveQuestionTab(id, store.tab)) + onCleanup(() => clearActiveQuestionTab(id)) const question = createMemo(() => questions()[store.tab]) const confirm = createMemo(() => !single() && store.tab === questions().length) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index 5737bfbcd9..b87eeafae9 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -122,13 +122,6 @@ export const TaskHeader: Component = (props) => { : []), ] }) - const activitySummary = createMemo(() => { - if (memory.error()) return memory.error()! - if (!memory.enabled()) return language.t("chat.memory.project.disabled") - const lines = activityLines() - if (lines.length === 0) return language.t("chat.memory.activity.idle") - return [language.t("chat.memory.status.active"), ...lines].join(" · ") - }) const activityItems = createMemo(() => activity() .flatMap((item) => { @@ -157,6 +150,8 @@ export const TaskHeader: Component = (props) => { ) const activityTooltip = () => ( <> +
{language.t("settings.context.title")}
+
{memoryStatus()}
{activitySummaryView()} 0}>
@@ -292,7 +287,9 @@ export const TaskHeader: Component = (props) => { startRename() }} > - {title()} + + {title()} +
@@ -318,107 +315,117 @@ export const TaskHeader: Component = (props) => { - + } > -
-
{memoryStatus()}
- {activitySummaryView()} - +
+
{language.t("settings.context.title")}
+ +
+
+ + {language.t("settings.context.compaction.title")} +
+
- - } - > -
- - - - - -
-
- {language.t("chat.memory.verbose")} - memory.verbose(next ? "on" : "off")} - > - {language.t("chat.memory.verbose")} - -
+
+
+
+
+ + {language.t("settings.context.memory.title")} +
+
{memoryStatus()}
+ {activitySummaryView()} +
+ memory.enable()} + > + {language.t("chat.memory.enable")} + + } + > + + + + + + +
+ +
+ {language.t("chat.memory.verbose")} + memory.verbose(next ? "on" : "off")} + > + {language.t("chat.memory.verbose")} + +
+
+
- - - session.compact()} - aria-label={language.t("command.session.compact")} - /> - - = (props) => { {(item) => ( )} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/tool-default-open.ts b/packages/kilo-vscode/webview-ui/src/components/chat/tool-default-open.ts new file mode 100644 index 0000000000..8bf72cb031 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/tool-default-open.ts @@ -0,0 +1,12 @@ +import type { Part as SDKPart, ToolPart } from "@kilocode/sdk/v2" + +const EDIT_TOOLS = new Set(["edit", "write", "apply_patch"]) +const TERMINAL_TOOLS = new Set(["bash", "background_process"]) + +export function toolDefaultOpen(part: SDKPart, terminal: boolean, edit: boolean) { + if (part.type !== "tool") return undefined + const tool = (part as unknown as ToolPart).tool + if (TERMINAL_TOOLS.has(tool)) return terminal + if (EDIT_TOOLS.has(tool)) return edit + return undefined +} diff --git a/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx b/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx index 79e7d8b8ff..c2258a7d5c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx @@ -169,7 +169,9 @@ const CloudSessionList: Component = (props) => { > {(s) => ( <> - {s.title} + + {s.title} + {formatRelativeDate(s.updatedAt)} )} diff --git a/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx b/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx index 6d412adb77..53c7b4654f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx @@ -210,7 +210,9 @@ const SessionList: Component = (props) => { > {(s) => ( <> - {name(s)} + + {name(s)} + {formatRelativeDate(s.updatedAt)} {language.t("session.current")} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index d109bb11e3..7e67d9df60 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -208,7 +208,7 @@ const Settings: Component = (props) => { - + {language.t("settings.indexing.title")} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 06ee1173e2..c378223b3a 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -1084,6 +1084,7 @@ export const SessionProvider: ParentComponent = (props) => { if (handleModelUsageMessage(message)) return refreshModelUsageForMessage(message) if (handleStreamMessage(message)) return + handleCommandCompletion(message) cah.handleMessage(message) switch (message.type) { case "sessionCreated": @@ -1602,6 +1603,10 @@ export const SessionProvider: ParentComponent = (props) => { rebuildToolParts(message.sessionID, store.messages[message.sessionID] ?? []) } + function handleCommandCompletion(message: ExtensionMessage): void { + if (message.type === "sessionCommandCompleted") finishSubmission(message.messageID) + } + function handlePartUpdated( sessionID: string | undefined, messageID: string | undefined, diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index d950a3e752..f97d6fe945 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -1037,31 +1037,40 @@ const mockMemory: MemoryContextValue = { forget: () => {}, } +const memoryHeader = (width: string) => { + const session = { + ...mockSessionValue({ id: SESSION_ID, status: "idle" }), + messages: () => [{ id: "msg-001" }] as any[], + contextUsage: () => ({ tokens: 34300, percentage: 17 }), + costBreakdown: () => [{ label: "Session", cost: 0.64 }], + currentSession: () => ({ + id: SESSION_ID, + title: "Integrate project memory", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + } + return ( + + + +
+ +
+
+
+
+ ) +} + export const TaskHeaderWithMemory: Story = { name: "TaskHeader — with memory enabled", - render: () => { - const session = { - ...mockSessionValue({ id: SESSION_ID, status: "idle" }), - messages: () => [{ id: "msg-001" }] as any[], - currentSession: () => ({ - id: SESSION_ID, - title: "Integrate project memory", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }), - } - return ( - - - -
- -
-
-
-
- ) - }, + render: () => memoryHeader("380px"), +} + +export const TaskHeaderWithMemory200: Story = { + name: "TaskHeader — with memory enabled 200", + render: () => memoryHeader("200px"), } const usageTokens = { input: 25_900_000, output: 52_000, reasoning: 4_100, cache: { read: 10_500_000, write: 80_000 } } diff --git a/packages/kilo-vscode/webview-ui/src/styles/task-header.css b/packages/kilo-vscode/webview-ui/src/styles/task-header.css index 5d190a81ef..e6097cd989 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/task-header.css +++ b/packages/kilo-vscode/webview-ui/src/styles/task-header.css @@ -50,6 +50,12 @@ white-space: nowrap; } +@media (max-width: 360px) { + [data-component="task-header"] { + padding-inline: 8px; + } +} + /* ============================================ Task Header Todos ============================================ */ @@ -467,7 +473,7 @@ font-variant-numeric: tabular-nums; } -.task-header-memory-trigger { +.task-header-context-trigger { all: unset; position: relative; display: inline-flex; @@ -478,18 +484,10 @@ border-radius: 3px; color: var(--vscode-foreground); cursor: pointer; - opacity: 0.4; - transition: opacity 0.15s ease; } -.task-header-memory-trigger--enabled { - color: var(--vscode-foreground); - opacity: 1; -} - -.task-header-memory-trigger:hover { +.task-header-context-trigger:hover { background: var(--vscode-toolbar-hoverBackground); - opacity: 1; } [data-slot="task-header-memory-dot"] { @@ -502,7 +500,7 @@ background: var(--vscode-testing-iconPassed, #73c991); } -.task-header-memory-popover { +.task-header-context-popover { width: min(280px, calc(100vw - 16px)); max-width: calc(100vw - 16px); white-space: normal; @@ -515,20 +513,62 @@ overflow-wrap: break-word; } -[data-slot="task-header-memory-menu"] { +[data-slot="task-header-context-tooltip-title"] { + margin-bottom: 4px; + color: var(--vscode-foreground); + font-weight: 600; +} + +[data-slot="task-header-context-tooltip-status"] { + margin-bottom: 2px; + color: var(--vscode-foreground); +} + +[data-slot="task-header-context-menu"] { display: flex; flex-direction: column; - gap: 8px; padding: 8px; } +[data-slot="task-header-context-title"] { + padding: 2px 4px 8px; + color: var(--vscode-foreground); + font-size: var(--kilo-font-size-12); + font-weight: 600; +} + +[data-slot="task-header-context-section"] { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 0; +} + +[data-slot="task-header-context-section"] + [data-slot="task-header-context-section"] { + margin-top: 6px; + padding-top: 12px; + border-top: 1px solid var(--vscode-widget-border, var(--vscode-editorWidget-border)); +} + +[data-slot="task-header-context-section-title"] { + display: flex; + align-items: center; + gap: 4px; + padding: 0 4px; + color: var(--vscode-descriptionForeground); + font-size: var(--kilo-font-size-11); + font-weight: 600; +} + [data-slot="task-header-memory-status"] { + padding: 0 4px; color: var(--vscode-foreground); font-size: var(--kilo-font-size-11); } [data-slot="task-header-memory-activity"] { min-width: 0; + padding: 0 4px; color: var(--vscode-descriptionForeground); font-size: var(--kilo-font-size-11); white-space: normal; @@ -558,13 +598,13 @@ overflow-wrap: break-word; } -[data-slot="task-header-memory-actions"] { +[data-slot="task-header-context-actions"] { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } -[data-slot="task-header-memory-action"] { +[data-slot="task-header-context-action"] { border: 0; border-radius: 3px; padding: 4px 6px; @@ -576,15 +616,15 @@ cursor: pointer; } -[data-slot="task-header-memory-action"]:last-child:nth-child(odd) { +[data-slot="task-header-context-action"]:last-child:nth-child(odd) { grid-column: 1 / -1; } -[data-slot="task-header-memory-action"]:hover:not(:disabled) { +[data-slot="task-header-context-action"]:hover:not(:disabled) { background: var(--vscode-list-hoverBackground); } -[data-slot="task-header-memory-action"]:disabled { +[data-slot="task-header-context-action"]:disabled { color: var(--vscode-disabledForeground); cursor: default; } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index c3132d9d77..5ff64c62c5 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -109,6 +109,11 @@ export interface SendMessageFailedMessage { review?: import("../../../../src/shared/review-comments").ReviewMessageData } +export interface SessionCommandCompletedMessage { + type: "sessionCommandCompleted" + messageID: string +} + // Wire shape lives in src/shared/stream-messages.ts; narrow `part` to the // webview's concrete union. export type PartUpdatedMessage = PartUpdate @@ -643,6 +648,11 @@ export interface AgentManagerSessionForkedMessage { worktreeId?: string } +export interface AgentManagerSessionClosedMessage { + type: "agentManager.sessionClosed" + sessionId: string +} + // Full state push from extension to webview export interface AgentManagerStateMessage { type: "agentManager.state" @@ -1086,6 +1096,7 @@ export type ExtensionMessage = | ConnectionStateMessage | ErrorMessage | SendMessageFailedMessage + | SessionCommandCompletedMessage | PartUpdatedMessage | PartsUpdatedMessage | PartRemovedMessage @@ -1162,6 +1173,7 @@ export type ExtensionMessage = | AgentManagerWorktreeSetupMessage | AgentManagerSessionAddedMessage | AgentManagerSessionForkedMessage + | AgentManagerSessionClosedMessage | AgentManagerStateMessage | AgentManagerRunStatusMessage | AgentManagerKeybindingsMessage diff --git a/packages/kilo-web-ui/package.json b/packages/kilo-web-ui/package.json index 69f8a6241d..8135728ce8 100644 --- a/packages/kilo-web-ui/package.json +++ b/packages/kilo-web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-web-ui", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "exports": { diff --git a/packages/llm/package.json b/packages/llm/package.json index 750e9cbd45..1eb0522af4 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.9", + "version": "7.4.11", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 3c073a6b87..29925d3baa 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,36 @@ # @kilocode/cli +## 7.4.11 + +### Minor Changes + +- [#12255](https://github.com/Kilo-Org/kilocode/pull/12255) [`e084ab7`](https://github.com/Kilo-Org/kilocode/commit/e084ab7492eb6f330768157663b29c347dc0fa18) - Improve CLI project-memory controls, status, activity indicators, and optional recall details. + +- [#12250](https://github.com/Kilo-Org/kilocode/pull/12250) [`bd69158`](https://github.com/Kilo-Org/kilocode/commit/bd69158131aafdcc2f44aede22b573c2b0432f21) - Support verbose project-memory settings and show recalled memory snippets in conversation markers when enabled. + +### Patch Changes + +- [#12242](https://github.com/Kilo-Org/kilocode/pull/12242) [`06c2337`](https://github.com/Kilo-Org/kilocode/commit/06c23379d8e07b583591cf3296c6fab4177d3a26) - Speed up local conversation recall searches on large histories. + +- [#12252](https://github.com/Kilo-Org/kilocode/pull/12252) [`e67635d`](https://github.com/Kilo-Org/kilocode/commit/e67635d2702d0352d7322a8cfd86f0786af13029) - Restore directory `@`-mentions by listing their entries without inlining child file contents. Untrusted external directory attachments remain denied. + +- [#12274](https://github.com/Kilo-Org/kilocode/pull/12274) [`5180c10`](https://github.com/Kilo-Org/kilocode/commit/5180c10c4f69500ce303437646371500a71dba46) - Show newly submitted messages immediately after reverting a conversation. + +- [#12086](https://github.com/Kilo-Org/kilocode/pull/12086) [`c654f1e`](https://github.com/Kilo-Org/kilocode/commit/c654f1e3d1efae339a20a44b6cd7e2f78deab4eb) Thanks [@rakshith1928](https://github.com/rakshith1928)! - Fix Grok 4.5 reasoning variants not showing up in the model picker. + +- [#12267](https://github.com/Kilo-Org/kilocode/pull/12267) [`e3124d3`](https://github.com/Kilo-Org/kilocode/commit/e3124d31472b8fa652418fae9e583ef2b29c16e9) - Retry incomplete model responses that end without final output or tool activity while preserving partial answers and completed tools. + +- Updated dependencies [[`319f159`](https://github.com/Kilo-Org/kilocode/commit/319f159ac333d18855a72ddb1fa61ed471ebf2d9), [`30e7ec4`](https://github.com/Kilo-Org/kilocode/commit/30e7ec4ab45fac724b41ec0b4342e272e7f584d2), [`bd69158`](https://github.com/Kilo-Org/kilocode/commit/bd69158131aafdcc2f44aede22b573c2b0432f21)]: + - @kilocode/kilo-gateway@7.4.10 + - @kilocode/kilo-memory@7.5.0 + - @kilocode/sdk@7.5.0 + - @kilocode/kilo-indexing@7.4.10 + - @kilocode/kilo-telemetry@7.4.10 + - @kilocode/plugin@7.4.10 + - @opencode-ai/ui@7.4.10 + - @opencode-ai/server@7.4.10 + - @kilocode/plugin-atomic-chat@7.4.10 + ## 7.4.9 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index eb0c1dad4d..f62473bbc1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.9", + "version": "7.4.11", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index d9d63bc434..6bb8eaf10a 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -25,6 +25,8 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" // kilocode_change end +import { createParentRemoteExitBridge, type RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change +import type { Exit } from "@opencode-ai/tui/context/exit" // kilocode_change declare global { const KILO_WORKER_PATH: string @@ -32,12 +34,50 @@ declare global { type RpcClient = ReturnType> -// kilocode_change start - share the upstream TUI runner between daemon and worker paths -async function start(input: StartInput) { +// kilocode_change start - bridge remote exit only for the embedded worker transport +export function embeddedRemoteExitClient(external: boolean, client: T | undefined): T | undefined { + return external ? undefined : client +} + +export async function runEmbeddedRemoteExitBridge(input: { + client: RemoteExitBridgeClient + exit: Exit + done: Promise + timeoutMs?: number +}) { + const timeoutMs = input.timeoutMs ?? 5_000 + const bridge = createParentRemoteExitBridge(input.client, input.exit) + let ready = false + try { + try { + await withTimeout(bridge.ready(), timeoutMs, "remote exit startup timed out") + ready = true + } catch { + await bridge.dispose(timeoutMs).catch(() => {}) + } + await input.done + } finally { + if (ready) await bridge.dispose(timeoutMs).catch(() => {}) + } +} +// kilocode_change end + +// kilocode_change start - share the extracted TUI runner between daemon and worker paths +async function start(input: StartInput, remoteExitClient?: RpcClient) { const { Effect } = await import("effect") const { run } = await import("../tui/layer") const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") - await Effect.runPromise(run({ ...input, pluginHost: createLegacyTuiPluginHost() })) + const pluginHost = createLegacyTuiPluginHost() + if (!remoteExitClient) { + await Effect.runPromise(run({ ...input, pluginHost })) + return + } + + const ready = Promise.withResolvers() + const done = Effect.runPromise(run({ ...input, pluginHost, onExit: ready.resolve })) + const exit = await Promise.race([ready.promise, done.then(() => undefined)]) + if (!exit) return + await runEmbeddedRemoteExitBridge({ client: remoteExitClient, exit, done }) } // kilocode_change end @@ -336,28 +376,31 @@ export const TuiThreadCommand = cmd({ } // kilocode_change start - await start({ - // kilocode_change - shared lazy loader also supports daemon attach - url: transport.url, - async onSnapshot() { - const tui = writeHeapSnapshot("tui.heapsnapshot") - const server = await client.call("snapshot", undefined) - return [tui, server] + await start( + { + // kilocode_change - shared lazy loader also supports daemon attach + url: transport.url, + async onSnapshot() { + const tui = writeHeapSnapshot("tui.heapsnapshot") + const server = await client.call("snapshot", undefined) + return [tui, server] + }, + config, + directory: cwd, + fetch: transport.fetch, + headers: transport.headers, // kilocode_change + events: transport.events, + args: { + continue: args.continue, + sessionID: args.session, + agent: args.agent, + model: args.model, + prompt, + fork: args.fork, + }, }, - config, - directory: cwd, - fetch: transport.fetch, - headers: transport.headers, // kilocode_change - events: transport.events, - args: { - continue: args.continue, - sessionID: args.session, - agent: args.agent, - model: args.model, - prompt, - fork: args.fork, - }, - }) + embeddedRemoteExitClient(external, client), + ) // kilocode_change end } finally { await stop() diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 9116dbb72c..4543ab08c4 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -12,6 +12,7 @@ import { Effect } from "effect" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { KiloLog } from "@/kilocode/log" // kilocode_change import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" // kilocode_change +import { createWorkerRemoteExit } from "@/kilocode/cli/cmd/tui/remote-exit-worker" // kilocode_change ensureProcessMetadata("worker") // kilocode_change - retain worker role and parent run correlation await KiloLog.init() // kilocode_change - keep compatibility logs off the TUI terminal @@ -23,8 +24,17 @@ GlobalBus.on("event", (event) => { }) let server: Awaited> | undefined +const remoteExit = createWorkerRemoteExit(Rpc.emit) // kilocode_change export const rpc = { + // kilocode_change start - worker lifecycle hooks for remote exit + tuiReady() { + remoteExit.ready() + }, + tuiGone() { + remoteExit.gone() + }, + // kilocode_change end async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } const auth = ServerAuth.header() @@ -67,6 +77,7 @@ export const rpc = { ) }, async shutdown() { + remoteExit.shutdown() // kilocode_change await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) // kilocode_change start - Clear the Rpc message channel so the worker's event loop can drain and diff --git a/packages/opencode/src/kilo-sessions/attached-state.ts b/packages/opencode/src/kilo-sessions/attached-state.ts new file mode 100644 index 0000000000..a9af1638e2 --- /dev/null +++ b/packages/opencode/src/kilo-sessions/attached-state.ts @@ -0,0 +1,194 @@ +// kilocode_change - extracted state machine that separates presence-owned +// attached session ids from newly-created (pending) session announcements. +// `setPresence` (driven by the presence service) is authoritative for the +// presence set and adopts any pending ids it now covers. `announce` (driven +// by the create_session command) is duplicate-safe across both sets and +// resolves coherently with presence semantics on heartbeat failure so a +// presence-adopted id is never reported as an attach failure. +// +// Concurrency invariant: `lastSentKey` is the union the relay last observed +// through a successfully completed heartbeat. It is updated: +// - synchronously by `setPresence` (fire-and-forget, but the union we +// record is the current union which includes any in-flight pending ids, +// so a later `setPresence` with the same union correctly skips) +// - by `announce` only on a successful awaited heartbeat AND only when +// the announce still belongs to the current lifecycle (the captured +// `myGeneration` matches the current `generation`) +// It is NEVER updated on: +// - the synchronous prefix of `announce` (would let a concurrent +// `setPresence` skip its heartbeat because the cache falsely claims the +// relay is up to date, leaving the relay desynced if the announce then +// fails) +// - the failure branch of `announce` (the relay never saw the new union, +// and a concurrent setPresence may have already advanced lastSentKey to +// a newer state via its own fire-and-forget heartbeat) +// - a stale success after `reset()` (the relay's observed state belongs +// to the old lifecycle; the new lifecycle's setPresence calls must +// drive the new baseline, not a stale overwrite) + +export namespace AttachedState { + export type Options = { + /** Fires the relay heartbeat. May be fire-and-forget or awaited. Must + * reject (not resolve) when no relay connection is available so that + * `announce` cannot silently mark a session as attached. */ + heartbeat: () => Promise + log?: { warn: (msg: string, meta?: unknown) => void } + } + + export type Interface = { + /** Replace the presence-owned set. Adopts any pending ids now covered by + * presence and fires a heartbeat if and only if the current union + * diverges from `lastSentKey`. The fire-and-forget heartbeat's union + * is recorded into `lastSentKey` synchronously (the current union + * already includes any in-flight pending ids). */ + setPresence(ids: readonly string[]): void + /** Awaitable duplicate-safe announcement. No-ops when the id is already + * present in either set. On heartbeat failure rolls back only its own + * pending entry, does NOT touch `lastSentKey`, and re-throws — unless + * presence adopted the id while the heartbeat was in flight, in which + * case presence is authoritative and the attach resolves successfully. + * On success advances `lastSentKey` to the current union. */ + announce(id: string): Promise + /** Current union of presence ∪ pending for the next heartbeat payload. */ + union(): ReadonlySet + /** Clear both sets across a connection lifecycle. The next setPresence + * call after reset will fire a heartbeat because the baseline key is + * empty. */ + reset(): void + } + + // kilocode_change - collision-safe union key. The historical "|" join + // was ambiguous: {"a", "b"} and {"a|b"} both encoded to "a|b" so two + // distinct id sets could collide. Length-prefixing each id makes the + // encoding unambiguous for any string id: knowing the prefix length + // tells the decoder exactly how many characters of id follow, so no + // delimiter can ever escape its enclosing id. + function keyOf(ids: Iterable): string { + const sorted = [...ids].sort() + const parts: string[] = [] + for (const id of sorted) parts.push(`${id.length}:${id}`) + return parts.join(",") + } + + export function create(options: Options): Interface { + const presence = new Set() + const pending = new Set() + // kilocode_change - in-flight dedup. Concurrent announce(id) callers + // share the same Promise so they observe one consistent outcome and + // the heartbeat fires at most once per id. The owner is the caller + // that installed the Promise; only it manages the map entry. On settle + // the owner clears the entry if the map still points to its Promise + // (a later announce may have replaced it). Joiners only await. + const inflight = new Map>() + // kilocode_change end + let lastSentKey = "" + // kilocode_change - lifecycle generation. Incremented on reset() so a + // late success from an in-flight announce started before the reset + // cannot overwrite the new lifecycle's lastSentKey. The old completion + // would otherwise write keyOf(union()) using the new generation's union, + // causing redundant/stale change detection in subsequent setPresence calls. + let generation = 0 + + function union(): Set { + const out = new Set(presence) + for (const id of pending) out.add(id) + return out + } + + function fireHeartbeat() { + void options + .heartbeat() + .catch((err) => options.log?.warn("attached-state heartbeat failed", { error: String(err) })) + } + + return { + setPresence(ids) { + const next = new Set(ids) + presence.clear() + for (const id of next) presence.add(id) + // Adopt any pending ids that presence now covers so the relay does + // not receive redundant heartbeat updates for ids it already knows. + for (const id of [...pending]) { + if (presence.has(id)) pending.delete(id) + } + const key = keyOf(union()) + if (key === lastSentKey) return + // Record the union synchronously so a subsequent setPresence with + // the same union is a no-op. The union already includes any + // in-flight pending ids, so a concurrent announce cannot poison it. + lastSentKey = key + fireHeartbeat() + }, + + async announce(id) { + if (presence.has(id)) return + // kilocode_change - join an in-flight Promise for this id instead + // of starting a second heartbeat. + const existing = inflight.get(id) + if (existing) { + await existing + return + } + if (pending.has(id)) { + // A previous announce already resolved and is awaiting presence + // adoption. No further work to do. + return + } + // kilocode_change - capture the lifecycle generation so a late + // success after reset() cannot overwrite the new lifecycle's + // lastSentKey with keyOf(union()) computed from the new state. + const myGeneration = generation + const owned = (async () => { + pending.add(id) + try { + await options.heartbeat() + } catch (err) { + // Roll back only the entry this call added. If presence adopted + // the id while the heartbeat was in flight, presence is the + // authoritative owner and the attach succeeded from the + // caller's perspective — resolve cleanly and leave the union + // alone. Otherwise the id is truly unattached: drop the pending + // entry and surface the failure. lastSentKey is never touched + // here because the relay never observed the new union and a + // concurrent setPresence may have already advanced it. + if (presence.has(id)) return + pending.delete(id) + throw err + } + // Success: the relay now has the union that includes this id. + // Advance lastSentKey so the next setPresence with the same union + // is a no-op. The id stays in `pending` until presence adopts it; + // this keeps the union stable across presence churn. If a + // reset() happened while the heartbeat was in flight, the + // captured generation no longer matches and we must NOT write + // lastSentKey — the new lifecycle owns the baseline now. + if (myGeneration !== generation) return + lastSentKey = keyOf(union()) + })() + inflight.set(id, owned) + try { + await owned + } finally { + // Only clear if the map still points to OUR promise; a later + // announce may have replaced it and we must not clobber that. + if (inflight.get(id) === owned) inflight.delete(id) + } + }, + + union() { + return union() + }, + + reset() { + presence.clear() + pending.clear() + inflight.clear() + lastSentKey = "" + // kilocode_change - bump the lifecycle generation so any in-flight + // announce started before this reset will skip its lastSentKey + // write on success (its captured generation no longer matches). + generation += 1 + }, + } + } +} diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 8433e6dcba..b1cbd36b16 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -25,6 +25,7 @@ import { Vcs } from "@/project/vcs" import simpleGit from "simple-git" import { RemoteWS } from "@/kilo-sessions/remote-ws" import { RemoteSender } from "@/kilo-sessions/remote-sender" +import { AttachedState } from "@/kilo-sessions/attached-state" import { SessionStatus } from "@/session/status" import { Telemetry } from "@kilocode/kilo-telemetry" import { Question } from "@/question" @@ -39,11 +40,8 @@ async function provide(input: { directory: string; fn: () => R }): Promise return provide(input) } -function same(a: Set, b: Set): boolean { - if (a.size !== b.size) return false - for (const id of a) if (!b.has(id)) return false - return true -} +// kilocode_change removed: `same` helper is no longer used now that the +// presence/pending set logic lives in AttachedState. export namespace KiloSessions { export const Event = { @@ -63,6 +61,11 @@ export namespace KiloSessions { export class Service extends Context.Service()("@kilocode/KiloSessions") {} const log = Log.create({ service: "kilo-sessions" }) + // kilocode_change - narrow `log` to the warn-only shape AttachedState needs. + // The full Logger has a typed `extra` arg that does not match the generic + // `meta?: unknown` contract; the warn body is forwarded to log.warn via an + // `unknown` cast below. + const attachedLog = { warn: (msg: string, meta?: unknown) => log.warn(msg, meta as never) } const runtime = makeRuntime(Auth.Service, Auth.defaultLayer) const Uuid = z.uuid() @@ -210,7 +213,19 @@ export namespace KiloSessions { let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined let enabling: Promise | undefined let remoteSeq = 0 - const attached = new Set() + // kilocode_change start - separate presence-owned attached session ids from + // newly-created (pending) session announcements so a concurrent presence + // update cannot drop a pending id and a heartbeat failure cannot delete a + // presence-owned id. The heartbeat closure throws when no remote connection + // is available so `announce` cannot silently mark a session as attached; + // create_session's catch block turns that into the sanitized failure + // response and the user retries manually. + const attachedState = AttachedState.create({ + heartbeat: () => + remote ? remote.conn.heartbeat() : Promise.reject(new Error("attachRemoteSession: no remote connection")), + log: attachedLog, + }) + // kilocode_change end const statusSyncs = new Map() const STATUS_TIMEOUT_MS = 3_000 @@ -425,8 +440,11 @@ export namespace KiloSessions { const { AppRuntime } = await import("@/effect/app-runtime") const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) const statuses: Record = Object.fromEntries(statusMap) + // kilocode_change - advertise both presence-owned and pending-created ids + // so the relay learns about new sessions before the next periodic + // heartbeat and the create_session response can be sent. const ids = new Set(Object.keys(statuses)) - for (const id of attached) ids.add(id) + for (const id of attachedState.union()) ids.add(id) const results = await AppRuntime.runPromise( Session.Service.use((svc) => Effect.all( @@ -502,6 +520,10 @@ export namespace KiloSessions { remoteSeq += 1 const pending = !!enabling enabling = undefined + // kilocode_change - clear both presence and pending-created ids so the + // next remote connection lifecycle starts with a clean slate and stale + // pending announcements from a previous connection do not leak. + attachedState.reset() if (!remote) { if (pending) void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: false, connected: false }) return @@ -520,13 +542,21 @@ export namespace KiloSessions { } } export function setAttachedSessions(ids: readonly string[]) { - const next = new Set(ids) - if (same(next, attached)) return - attached.clear() - for (const id of next) attached.add(id) - if (remote) void remote.conn.heartbeat().catch((err) => log.warn("heartbeat failed", { error: String(err) })) + // kilocode_change - delegate to the two-set state so a concurrent create + // announcement is not dropped by a presence clear+rebuild. + attachedState.setPresence(ids) } + // kilocode_change start - duplicate-safe single-session attach used by the + // remote create_session command. Delegates to the two-set state so the + // announcement is preserved across a concurrent presence replacement and a + // heartbeat failure rolls back only the entry this call added (a + // presence-owned id is never reachable here because the factory guards it). + export async function attachRemoteSession(id: string) { + await attachedState.announce(id) + } + // kilocode_change end + export async function create(sessionId: string) { const result = await bootstrap(sessionId) if (!result) return { id: "", ingestPath: "" } diff --git a/packages/opencode/src/kilo-sessions/remote-command.ts b/packages/opencode/src/kilo-sessions/remote-command.ts new file mode 100644 index 0000000000..3e9ac87d72 --- /dev/null +++ b/packages/opencode/src/kilo-sessions/remote-command.ts @@ -0,0 +1,323 @@ +import type { Info as CommandInfo } from "@/command" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import type { MessageV2 } from "@/session/message-v2" +import type { SessionPrompt } from "@/session/prompt" +import type { Info as SessionInfo } from "@/session/session" +import { MessageID, type SessionID } from "@/session/schema" +import { RemoteExit } from "@/kilo-sessions/remote-exit" +import z from "zod" + +export namespace RemoteCommand { + export const MAX_COMMANDS = 256 + export const MAX_STRING_LENGTH = 2_000 + export const MAX_ARGUMENTS_LENGTH = 32_768 + export const MAX_HINTS = 32 + export const MAX_RESULT_BYTES = 512 * 1024 + + export const ListRequest = z + .object({ + protocolVersion: z.literal(1), + }) + .strict() + + export const ExitRequest = z + .object({ + protocolVersion: z.literal(1), + }) + .strict() + + export const SendRequest = z + .object({ + protocolVersion: z.literal(1), + command: z.string().min(1).max(MAX_STRING_LENGTH), + arguments: z.string().max(MAX_ARGUMENTS_LENGTH), + messageID: z.string().startsWith("msg").max(MAX_STRING_LENGTH).optional(), + model: z + .object({ + providerID: z.string().min(1).max(MAX_STRING_LENGTH), + modelID: z.string().min(1).max(MAX_STRING_LENGTH), + }) + .strict() + .optional(), + variant: z.string().max(MAX_STRING_LENGTH).optional(), + }) + .strict() + export type SendRequest = z.infer + + export const Info = z + .object({ + name: z.string().min(1).max(MAX_STRING_LENGTH), + description: z.string().max(MAX_STRING_LENGTH).optional(), + agent: z.string().max(MAX_STRING_LENGTH).optional(), + model: z.string().max(MAX_STRING_LENGTH).optional(), + source: z.enum(["command", "mcp", "skill"]).optional(), + hints: z.array(z.string().max(MAX_STRING_LENGTH)).max(MAX_HINTS), + subtask: z.boolean().optional(), + }) + .strict() + export type Info = z.infer + + export const Response = z + .object({ + protocolVersion: z.literal(1), + commands: z.array(Info).max(MAX_COMMANDS), + }) + .strict() + export type Response = z.infer + + // The only entry from BUILTIN_COMMANDS (kilocode/session/builtin-commands) exposed + // remotely: `summarize` is a local alias for the same compaction flow, so listing + // both would just duplicate the suggestion. + const compact: Info = { + name: "compact", + description: "compact the current session context", + hints: [], + } + + const exit: Info = { + name: "exit", + description: "Exit the CLI", + hints: [], + } + + export function executable(name: string): boolean { + return name !== exit.name + } + + function compare(a: Info, b: Info) { + if (a.name < b.name) return -1 + if (a.name > b.name) return 1 + return 0 + } + + // Truncates the alphabetical tail to stay within the count and byte caps. + // Required synthesized entries are seeded first so truncation cannot remove + // them; sizes are accumulated per entry to keep this a single pass. + function truncate(commands: Info[]): Info[] { + const encoder = new TextEncoder() + const measure = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength + const required = [commands.find((item) => item.name === compact.name) ?? compact] + const processExit = commands.find((item) => item.name === exit.name) + if (processExit) required.push(processExit) + const selected: Info[] = [...required] + let budget = + MAX_RESULT_BYTES - + measure({ protocolVersion: 1, commands: [] }) - + required.reduce((total, item, index) => total + measure(item) + (index ? 1 : 0), 0) + for (const item of commands) { + if (required.includes(item)) continue + if (selected.length >= MAX_COMMANDS) break + const bytes = measure(item) + 1 // +1 for the separating comma + if (bytes > budget) break + budget -= bytes + selected.push(item) + } + return selected.sort(compare) + } + + // Validates a single source against the catalog caps, dropping skills and + // entries whose fields exceed the per-field limits. Shared by build() and + // the compact shadow check so discovery and execution apply the same rules. + function parse(source: CommandInfo): Info | undefined { + if (source.source === "skill" || source.name === exit.name) return + const item = Info.safeParse({ + name: source.name, + ...(source.description !== undefined ? { description: source.description } : {}), + ...(source.agent !== undefined ? { agent: source.agent } : {}), + ...(source.model !== undefined ? { model: source.model } : {}), + ...(source.source !== undefined ? { source: source.source } : {}), + hints: source.hints, + ...(source.subtask !== undefined ? { subtask: source.subtask } : {}), + }) + return item.success ? item.data : undefined + } + + export function build(items: ReadonlyArray, exitAvailable = false): Response { + const names = new Set() + const commands: Info[] = [] + + for (const source of items) { + const item = parse(source) + if (!item || names.has(item.name)) continue + names.add(item.name) + commands.push(item) + } + + // truncate() sorts its output after preserving synthesized entries, so the + // response stays alphabetized regardless of input order. + if (!names.has(compact.name)) commands.push(compact) + if (exitAvailable) commands.push(exit) + return Response.parse({ protocolVersion: 1, commands: truncate(commands) }) + } + + export type ExecuteInput = SendRequest & { sessionID: SessionID; catalog: Response } + + export type Services = { + list: () => Promise + exitAvailable?: () => boolean + command: (input: SessionPrompt.CommandInput) => Promise + session: { + get: (sessionID: SessionID) => Promise + messages: (sessionID: SessionID) => Promise + } + agent: { default: () => Promise } + provider: { default: () => Promise<{ providerID: string; modelID: string }> } + revert: { cleanup: (session: SessionInfo) => Promise } + compaction: { + create: (input: { + sessionID: SessionID + agent: string + model: { providerID: ProviderV2.ID; modelID: ModelV2.ID } + auto: boolean + }) => Promise + } + prompt: { loop: (sessionID: SessionID) => Promise } + } + + export type Interface = { + list: () => Promise + execute: (input: ExecuteInput) => Promise + } + + export function create(services: Services): Interface { + return { + list: async () => build(await services.list(), services.exitAvailable?.() ?? false), + execute: async (input) => { + // kilocode_change - enforce membership in the supplied bounded + // remote-safe catalog. The dispatcher's preflight also gates + // membership before the ACK, but execute() is the last line of + // defense: a caller (or a future caller) that bypasses the + // dispatcher must still not reach services.command() for a name + // the mobile client was never offered. Reject with a specific + // error so the failure is distinguishable from runtime faults. + const catalogNames = new Set(input.catalog.commands.map((item) => item.name)) + if (!executable(input.command) || !catalogNames.has(input.command)) { + throw new Error(`unknown slash command: ${input.command}`) + } + // A registered command named `compact` shadows the built-in whenever it + // appears in the preflight catalog. The built-in "compact" sentinel + // carries no `source`, so checking for source=="command"|"mcp" rules it + // out and falls back to the built-in path. + const registeredCompact = input.catalog.commands.find( + (item) => item.name === compact.name && (item.source === "command" || item.source === "mcp"), + ) + const shadowed = input.command === compact.name && !!registeredCompact + if (input.command === compact.name && !shadowed) { + const session = await services.session.get(input.sessionID) + await services.revert.cleanup(session) + const messages = await services.session.messages(input.sessionID) + const user = messages.findLast((message) => message.info.role === "user") + const agent = + (user?.info.role === "user" ? user.info.agent : undefined) ?? + session.agent ?? + (await services.agent.default()) + const model = + input.model ?? + (session.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined) ?? + (user?.info.role === "user" + ? { providerID: user.info.model.providerID, modelID: user.info.model.modelID } + : undefined) ?? + (await services.provider.default()) + await services.compaction.create({ + sessionID: input.sessionID, + agent, + model: { + providerID: ProviderV2.ID.make(model.providerID), + modelID: ModelV2.ID.make(model.modelID), + }, + auto: false, + }) + await services.prompt.loop(input.sessionID) + return + } + await services.command({ + sessionID: input.sessionID, + command: input.command, + arguments: input.arguments, + ...(input.messageID ? { messageID: MessageID.make(input.messageID) } : {}), + ...(input.model ? { model: `${input.model.providerID}/${input.model.modelID}` } : {}), + ...(input.variant !== undefined ? { variant: input.variant } : {}), + }) + }, + } + } + + export function live(): Interface { + return create({ + exitAvailable: () => !!RemoteExit.get(), + list: async () => { + const [{ AppRuntime }, { Command }] = await Promise.all([import("@/effect/app-runtime"), import("@/command")]) + return AppRuntime.runPromise(Command.Service.use((service) => service.list())) + }, + command: async (input) => { + const [{ AppRuntime }, { SessionPrompt }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/prompt"), + ]) + await AppRuntime.runPromise(SessionPrompt.Service.use((service) => service.command(input))) + }, + session: { + get: async (sessionID) => { + const [{ AppRuntime }, { Session }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/session"), + ]) + return AppRuntime.runPromise(Session.Service.use((service) => service.get(sessionID))) + }, + messages: async (sessionID) => { + const [{ AppRuntime }, { Session }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/session"), + ]) + return AppRuntime.runPromise(Session.Service.use((service) => service.messages({ sessionID }))) + }, + }, + agent: { + default: async () => { + const [{ AppRuntime }, { Agent }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/agent/agent"), + ]) + return AppRuntime.runPromise(Agent.Service.use((service) => service.defaultAgent())) + }, + }, + provider: { + default: async () => { + const [{ AppRuntime }, { Provider }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/provider/provider"), + ]) + return AppRuntime.runPromise(Provider.Service.use((service) => service.defaultModel())) + }, + }, + revert: { + cleanup: async (session) => { + const [{ AppRuntime }, { SessionRevert }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/revert"), + ]) + await AppRuntime.runPromise(SessionRevert.Service.use((service) => service.cleanup(session))) + }, + }, + compaction: { + create: async (input) => { + const [{ AppRuntime }, { SessionCompaction }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/compaction"), + ]) + await AppRuntime.runPromise(SessionCompaction.Service.use((service) => service.create(input))) + }, + }, + prompt: { + loop: async (sessionID) => { + const [{ AppRuntime }, { SessionPrompt }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/prompt"), + ]) + await AppRuntime.runPromise(SessionPrompt.Service.use((service) => service.loop({ sessionID }))) + }, + }, + }) + } +} diff --git a/packages/opencode/src/kilo-sessions/remote-exit.ts b/packages/opencode/src/kilo-sessions/remote-exit.ts new file mode 100644 index 0000000000..de5482e5d2 --- /dev/null +++ b/packages/opencode/src/kilo-sessions/remote-exit.ts @@ -0,0 +1,17 @@ +export namespace RemoteExit { + export type Callback = () => Promise + + let current: { callback: Callback; token: symbol } | undefined + + export function register(callback: Callback): () => void { + const token = Symbol() + current = { callback, token } + return () => { + if (current?.token === token) current = undefined + } + } + + export function get(): Callback | undefined { + return current?.callback + } +} diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 8d63294cab..84a747560a 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -1,3 +1,5 @@ +import { RemoteCommand } from "@/kilo-sessions/remote-command" +import { RemoteExit } from "@/kilo-sessions/remote-exit" import { RemoteModelCatalog } from "@/kilo-sessions/remote-model-catalog" import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" import type { RemoteWS } from "@/kilo-sessions/remote-ws" @@ -41,8 +43,24 @@ const SuggestionData = z.object({ index: z.number().int().nonnegative(), }) +// kilocode_change start - create_session: strict v1 request, no other fields accepted +const CreateSessionRequest = z + .object({ + protocolVersion: z.literal(1), + }) + .strict() +// kilocode_change end + const decodeSessionID = Schema.decodeUnknownOption(SessionID) +// kilocode_change start - redact anything but the error class so messages/credentials +// never end up in logs +function errorName(error: unknown): string { + if (error instanceof Error && error.name) return error.name + return typeof error +} +// kilocode_change end + // kilocode_change start — lazy init to avoid circular dependency // (Server → RemoteRoutes → RemoteSender → SessionPrompt at module load time) type RemotePromptInput = Omit & { @@ -102,13 +120,34 @@ export namespace RemoteSender { session?: { readonly get: (sessionID: SessionID) => Promise readonly children: (sessionID: SessionID) => Promise + // kilocode_change start - injectable create hook for create_session. + // create_session only ever calls `create({})` for a root session, so the + // test hook is typed as the loose `() => Promise` shape. + // Production falls back to Session.Service.create with `{}`. + readonly create?: (input?: Record) => Promise + // kilocode_change - injectable remove hook used to roll back an orphan + // root session when attachSession fails after creation. The default + // delegates to Session.Service.remove and only swallows its own errors + // so the original attach failure is what reaches the caller. + readonly remove?: (sessionID: SessionID) => Promise + // kilocode_change end } + // kilocode_change start - duplicate-safe attach hook used by create_session. + // Production wires this to KiloSessions.attachRemoteSession so the attached + // set is mutated exactly once and the relay heartbeat fires only when the + // set actually changes. + attachSession?: (sessionID: SessionID) => Promise + // kilocode_change end catalog?: { readonly get: (sessionID: SessionID) => Promise readonly messages: (sessionID: SessionID) => Promise readonly providers: () => Promise> readonly default: () => Promise } + commands?: RemoteCommand.Interface + remoteExit?: { + get: () => RemoteExit.Callback | undefined + } } export type Sender = { @@ -190,6 +229,38 @@ export namespace RemoteSender { return AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID))) }, } + // kilocode_change start - orphan rollback for create_session: when + // sessionCreate succeeds but attachSession fails, the newly-created root + // session would otherwise stay in the DB with no relay awareness. The + // default remove() delegates to Session.Service.remove and swallows its + // own errors so the caller still observes the original attach failure. + const sessionRemove = + session.remove ?? + (async (id: SessionID) => { + const { AppRuntime } = await import("@/effect/app-runtime") + await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(id))) + }) + // kilocode_change end + // kilocode_change start - session create + duplicate-safe attach used by create_session + const sessionCreate = + session.create ?? + (async (input?: Record) => { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise( + Session.Service.use((svc) => svc.create(input as Parameters[0])), + ) + }) + const attachSession = + options.attachSession ?? + (async (id: SessionID) => { + const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions") + await KiloSessions.attachRemoteSession(id) + }) + // kilocode_change end + // kilocode_change start - injectable slash command discovery + execution + const commands = options.commands ?? RemoteCommand.live() + const remoteExit = options.remoteExit ?? RemoteExit + // kilocode_change end const sub = options.subscribe ?? @@ -369,6 +440,180 @@ export namespace RemoteSender { } function dispatch(msg: RemoteProtocol.Command) { + // kilocode_change start - slash command discovery and execution + if (msg.command === "list_commands") { + const parsed = RemoteCommand.ListRequest.safeParse(msg.data) + const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || Option.isNone(session)) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid list_commands request", + }) + return + } + const run = options.provide ?? provide + void (async () => { + try { + const info = await catalog.get(session.value) + const result = await run({ directory: info.directory, fn: () => commands.list() }) + options.conn.send({ type: "response", id: msg.id, result }) + } catch (error) { + options.log.error("list commands failed", { id: msg.id, error: errorName(error) }) + options.conn.send({ type: "response", id: msg.id, error: "failed to list commands" }) + } + })() + return + } + if (msg.command === "send_command") { + const parsed = RemoteCommand.SendRequest.safeParse(msg.data) + const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || Option.isNone(session)) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid send_command request", + }) + return + } + const run = options.provide ?? provide + const state = { acked: false } + void (async () => { + try { + const info = await catalog.get(session.value) + await run({ + directory: info.directory, + fn: async () => { + // Reject stale catalog entries (command deleted or renamed since the + // client listed) before the ACK — after it, failures are only logged. + const available = await commands.list() + if ( + !RemoteCommand.executable(parsed.data.command) || + !available.commands.some((item) => item.name === parsed.data.command) + ) { + options.conn.send({ type: "response", id: msg.id, error: "unknown slash command" }) + return + } + state.acked = true + options.conn.send({ type: "response", id: msg.id, result: {} }) + try { + await commands.execute({ ...parsed.data, sessionID: session.value, catalog: available }) + } catch (error) { + options.log.error("send command failed after ACK", { + id: msg.id, + operation: "send_command", + error: errorName(error), + }) + } + }, + }) + } catch (error) { + if (state.acked) { + options.log.error("send command context failed after ACK", { + id: msg.id, + operation: "send_command", + error: errorName(error), + }) + return + } + options.log.error("send command preflight failed", { + id: msg.id, + operation: "send_command", + error: errorName(error), + }) + options.conn.send({ type: "response", id: msg.id, error: "failed to send command" }) + } + })() + return + } + if (msg.command === "exit_cli") { + const parsed = RemoteCommand.ExitRequest.safeParse(msg.data) + const current = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || Option.isNone(current)) { + options.conn.send({ type: "response", id: msg.id, error: "invalid exit_cli command" }) + return + } + void (async () => { + try { + await session.get(current.value) + const exit = remoteExit.get() + if (!exit) { + options.conn.send({ type: "response", id: msg.id, error: "graceful exit unavailable" }) + return + } + options.conn.send({ type: "response", id: msg.id, result: {} }) + queueMicrotask(() => { + void exit().catch((error) => { + options.log.error("exit CLI failed after ACK", { + id: msg.id, + operation: "exit_cli", + error: errorName(error), + }) + }) + }) + } catch (error) { + options.log.error("exit CLI preflight failed", { id: msg.id, error: errorName(error) }) + options.conn.send({ type: "response", id: msg.id, error: "failed to exit CLI" }) + } + })() + return + } + if (msg.command === "create_session") { + // kilocode_change start - remote /new creation: root session, attached + heartbeat before response + const parsed = CreateSessionRequest.safeParse(msg.data) + const current = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || Option.isNone(current)) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid create_session command", + }) + return + } + const run = options.provide ?? provide + void (async () => { + try { + const result = await run({ + directory: (await session.get(current.value)).directory, + fn: async () => { + const created = await sessionCreate({}) + // attachSession is the duplicate-safe seam: it mutates the + // attached set exactly once and fires conn.heartbeat() only + // when the set actually changes, so the relay learns about + // the new session before we respond. + try { + await attachSession(created.id) + } catch (attachError) { + // Roll back the newly-created root session so the DB does + // not keep an orphan the relay never learned about. Swallow + // the cleanup error here — the original attach failure is + // what the caller must see, so we re-throw it below. + try { + await sessionRemove(created.id) + } catch (cleanupError) { + options.log.error("create session cleanup failed", { + id: msg.id, + error: errorName(cleanupError), + }) + } + throw attachError + } + return created + }, + }) + options.conn.send({ + type: "response", + id: msg.id, + result: { protocolVersion: 1, sessionID: result.id }, + }) + } catch (error) { + options.log.error("create session failed", { id: msg.id, error: errorName(error) }) + options.conn.send({ type: "response", id: msg.id, error: "failed to create session" }) + } + })() + return + } + // kilocode_change end if (msg.command === "list_models") { const parsed = RemoteModelCatalog.Request.safeParse(msg.data) const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() diff --git a/packages/opencode/src/kilocode/agent-manager/protocol.ts b/packages/opencode/src/kilocode/agent-manager/protocol.ts index a21cdb57c7..ef6c6f91ca 100644 --- a/packages/opencode/src/kilocode/agent-manager/protocol.ts +++ b/packages/opencode/src/kilocode/agent-manager/protocol.ts @@ -99,7 +99,13 @@ export const PromptRequest = Schema.Struct({ prompt: Prompt, }).annotate({ identifier: "AgentManagerPromptRequest" }) -export const Request = Schema.Union([OverviewRequest, PromptRequest]).annotate({ +export const StopRequest = Schema.Struct({ + ...Base, + operation: Schema.Literal("stop"), + targetSessionID: SessionID, +}).annotate({ identifier: "AgentManagerStopRequest" }) + +export const Request = Schema.Union([OverviewRequest, PromptRequest, StopRequest]).annotate({ identifier: "AgentManagerRequest", }) export type Request = Schema.Schema.Type @@ -115,7 +121,13 @@ export const PromptResult = Schema.Struct({ delivered: Schema.Literal(true), }).annotate({ identifier: "AgentManagerPromptResult" }) -export const Result = Schema.Union([OverviewResult, PromptResult]).annotate({ +export const StopResult = Schema.Struct({ + operation: Schema.Literal("stop"), + sessionID: SessionID, + stopped: Schema.Literal(true), +}).annotate({ identifier: "AgentManagerStopResult" }) + +export const Result = Schema.Union([OverviewResult, PromptResult, StopResult]).annotate({ identifier: "AgentManagerResult", }) export type Result = Schema.Schema.Type diff --git a/packages/opencode/src/kilocode/agent-manager/service.ts b/packages/opencode/src/kilocode/agent-manager/service.ts index 31279e3878..7aa7a9b42c 100644 --- a/packages/opencode/src/kilocode/agent-manager/service.ts +++ b/packages/opencode/src/kilocode/agent-manager/service.ts @@ -37,7 +37,7 @@ interface State { function matches(request: Request, result: Result) { if (request.operation === "overview") return result.operation === "overview" - return result.operation === "prompt" && result.sessionID === request.targetSessionID + return result.operation === request.operation && result.sessionID === request.targetSessionID } export interface Interface { diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-bridge.ts b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-bridge.ts new file mode 100644 index 0000000000..648760884f --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-bridge.ts @@ -0,0 +1,33 @@ +import type { Exit } from "@opencode-ai/tui/context/exit" +import { RemoteExitRpc } from "@/kilocode/cli/cmd/tui/remote-exit-rpc" +import { withTimeout } from "@/util/timeout" + +export type RemoteExitBridgeClient = { + on: (event: string, handler: () => void) => () => void + call: (method: "tuiReady" | "tuiGone", input: undefined) => Promise +} + +export function createParentRemoteExitBridge(client: RemoteExitBridgeClient, exit: Exit) { + let exiting = false + const unsubscribe = client.on(RemoteExitRpc.Event, () => { + if (exiting) return + exiting = true + exit() + }) + let disposed = false + + return { + ready() { + return client.call("tuiReady", undefined) + }, + async dispose(timeoutMs = 5_000) { + if (disposed) return + disposed = true + try { + await withTimeout(client.call("tuiGone", undefined), timeoutMs, "remote exit cleanup timed out") + } finally { + unsubscribe() + } + }, + } +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-rpc.ts b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-rpc.ts new file mode 100644 index 0000000000..380931d6d1 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-rpc.ts @@ -0,0 +1,3 @@ +export namespace RemoteExitRpc { + export const Event = "tui.exit" +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-worker.ts b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-worker.ts new file mode 100644 index 0000000000..67e42c10dd --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-worker.ts @@ -0,0 +1,22 @@ +import { RemoteExitRpc } from "@/kilocode/cli/cmd/tui/remote-exit-rpc" +import { RemoteExit } from "@/kilo-sessions/remote-exit" + +export function createWorkerRemoteExit(emit: (event: string, data: undefined) => void) { + let unregister: (() => void) | undefined + + const gone = () => { + unregister?.() + unregister = undefined + } + + return { + ready() { + gone() + unregister = RemoteExit.register(async () => { + emit(RemoteExitRpc.Event, undefined) + }) + }, + gone, + shutdown: gone, + } +} diff --git a/packages/opencode/src/kilocode/permission/agent-manager.ts b/packages/opencode/src/kilocode/permission/agent-manager.ts index 7a03779b20..17916b680d 100644 --- a/packages/opencode/src/kilocode/permission/agent-manager.ts +++ b/packages/opencode/src/kilocode/permission/agent-manager.ts @@ -2,12 +2,12 @@ import { type Rule } from "./rule" export namespace AgentManagerPermission { /** - * Prompting an existing Agent Manager session has an external side effect. + * Prompting or stopping an existing Agent Manager session has an external side effect. * Broad approvals for legacy session creation must not silently grant it. */ export function harden(permission: string, pattern: string, rule: Rule): Rule { - if (permission !== "agent_manager" || pattern !== "prompt" || rule.action !== "allow") return rule - if (rule.permission === "agent_manager" && rule.pattern === "prompt") return rule + if (permission !== "agent_manager" || !["prompt", "stop"].includes(pattern) || rule.action !== "allow") return rule + if (rule.permission === "agent_manager" && rule.pattern === pattern) return rule return { permission, pattern, action: "ask" } } } diff --git a/packages/opencode/src/kilocode/plugins/model-usage.ts b/packages/opencode/src/kilocode/plugins/model-usage.ts index 6fcbd25aa1..813b162275 100644 --- a/packages/opencode/src/kilocode/plugins/model-usage.ts +++ b/packages/opencode/src/kilocode/plugins/model-usage.ts @@ -52,8 +52,6 @@ const count = new Intl.NumberFormat("en-US") const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 6, }) export function formatCount(value: number) { @@ -68,6 +66,5 @@ export function formatRate(tokens: SessionModelUsage["totals"]["tokens"]) { export function formatCost(input: number) { const value = Math.max(0, Number.isFinite(input) ? input : 0) - if (value > 0 && value < 0.000001) return "<$0.000001" return currency.format(value) } diff --git a/packages/opencode/src/kilocode/plugins/sidebar-usage-row.tsx b/packages/opencode/src/kilocode/plugins/sidebar-usage-row.tsx new file mode 100644 index 0000000000..704c10dc54 --- /dev/null +++ b/packages/opencode/src/kilocode/plugins/sidebar-usage-row.tsx @@ -0,0 +1,39 @@ +import type { RGBA } from "@opentui/core" + +export function UsageRow(props: { label: string; value: string; color: RGBA }) { + return ( + + {props.label} + {props.value} + + ) +} + +export function ModelRow(props: { + label: string + steps: string + cost: string + expanded: boolean + text: RGBA + muted: RGBA + toggle: () => void +}) { + return ( + + + {props.expanded ? "▼" : "▶"} + + + + {props.label} + + + + {props.steps} + + + {props.cost} + + + ) +} diff --git a/packages/opencode/src/kilocode/plugins/sidebar-usage.tsx b/packages/opencode/src/kilocode/plugins/sidebar-usage.tsx index 296b9444d6..11df31ab1e 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-usage.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-usage.tsx @@ -15,11 +15,14 @@ import { select, type UsageResult, } from "@/kilocode/plugins/model-usage" +import { ModelRow, UsageRow } from "@/kilocode/plugins/sidebar-usage-row" const id = "internal:kilo-sidebar-usage" function View(props: { api: TuiPluginApi; session_id: string }) { + const [usageOpen, setUsageOpen] = createSignal(true) const [modelsOpen, setModelsOpen] = createSignal(true) + const [benchOpen, setBenchOpen] = createSignal(true) const [expanded, setExpanded] = createSignal(new Set()) const theme = () => props.api.theme.current const local = useLocal() @@ -42,10 +45,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) { return provider?.models[current.modelID]?.terminalBench }) const Row = (props: { label: string; value: string }) => ( - - {props.label} - {props.value} - + ) const toggle = (key: string) => setExpanded((current) => { @@ -85,37 +85,31 @@ function View(props: { api: TuiPluginApi; session_id: string }) { return ( - - Token Usage - - {unavailable() ? "Usage unavailable" : "Loading usage..."}} - > - {(data) => ( - <> - - - - - - - - - )} + setUsageOpen((open) => !open)}> + {usageOpen() ? "▼" : "▶"} + + Token Usage + + + + {unavailable() ? "Usage unavailable" : "Loading usage..."}} + > + {(data) => ( + <> + + + + + + + + + )} + - - {(value) => ( - - - Terminal Bench 2.0 - - - - - )} - {(data) => ( @@ -140,10 +134,10 @@ function View(props: { api: TuiPluginApi; session_id: string }) { Model - + Steps - + Cost @@ -152,27 +146,18 @@ function View(props: { api: TuiPluginApi; session_id: string }) { const key = `${props.session_id}/${model.providerID}/${model.modelID}` return ( - toggle(key)}> - - {expanded().has(key) ? "▼" : "▶"} - - - - - {Locale.truncate( - RoutedModelMeta.label(providers(), model) ?? model.modelID, - 19, - )} - - - - - {formatCount(model.steps)} - - - {formatCost(model.cost)} - - + toggle(key)} + /> @@ -197,6 +182,22 @@ function View(props: { api: TuiPluginApi; session_id: string }) { )} + + {(value) => ( + + setBenchOpen((open) => !open)}> + {benchOpen() ? "▼" : "▶"} + + Terminal Bench 2.0 + + + + + + + + )} + ) } diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 17607c9a7a..27c65f44cd 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -75,13 +75,18 @@ const PromptParams = Schema.Struct({ ), }) -export const Params = Schema.Union([StartParams, ListParams, PromptParams]) +const StopParams = Schema.Struct({ + action: Schema.Literal("stop"), + sessionID: SessionID, +}) + +export const Params = Schema.Union([StartParams, ListParams, PromptParams, StopParams]) const WireParams = Schema.Struct({ mode: Schema.optional(StartParams.fields.mode), versions: Schema.optional(StartParams.fields.versions), tasks: Schema.optional(StartParams.fields.tasks), - action: Schema.optional(Schema.Literals(["list", "prompt"])), + action: Schema.optional(Schema.Literals(["list", "prompt", "stop"])), filter: Schema.optional(ListParams.fields.filter), sessionID: Schema.optional(PromptParams.fields.sessionID), prompt: Schema.optional(PromptParams.fields.prompt), @@ -236,7 +241,7 @@ function select( export const AgentManagerTool = Tool.define< typeof Params, - { action: "start" | "list" | "prompt"; requestID?: string; count?: number; sessionID?: string }, + { action: "start" | "list" | "prompt" | "stop"; requestID?: string; count?: number; sessionID?: string }, AgentManager.Service | Bus.Service | Provider.Service, "agent_manager" >( @@ -275,27 +280,50 @@ export const AgentManagerTool = Tool.define< metadata: { action: "list", count }, } } + if (params.action === "prompt") { + yield* ctx.ask({ + permission: "agent_manager", + patterns: ["prompt"], + always: ["prompt"], + metadata: { action: "prompt", sessionID: params.sessionID }, + }) + const result = yield* run( + host.request({ + operation: "prompt", + sessionID: ctx.sessionID, + targetSessionID: params.sessionID, + prompt: params.prompt.trim(), + }), + ctx.abort, + ) + if (result.operation !== "prompt") + return yield* Effect.die(new Error("Agent Manager host returned the wrong result type")) + return { + title: "Prompt delivered", + output: `Delivered the prompt to Agent Manager session ${result.sessionID}. The session accepted it asynchronously.`, + metadata: { action: "prompt", sessionID: result.sessionID }, + } + } yield* ctx.ask({ permission: "agent_manager", - patterns: ["prompt"], - always: ["prompt"], - metadata: { action: "prompt", sessionID: params.sessionID }, + patterns: ["stop"], + always: ["stop"], + metadata: { action: "stop", sessionID: params.sessionID }, }) const result = yield* run( host.request({ - operation: "prompt", + operation: "stop", sessionID: ctx.sessionID, targetSessionID: params.sessionID, - prompt: params.prompt.trim(), }), ctx.abort, ) - if (result.operation !== "prompt") + if (result.operation !== "stop") return yield* Effect.die(new Error("Agent Manager host returned the wrong result type")) return { - title: "Prompt delivered", - output: `Delivered the prompt to Agent Manager session ${result.sessionID}. The session accepted it asynchronously.`, - metadata: { action: "prompt", sessionID: result.sessionID }, + title: "Session stopped", + output: `Stopped Agent Manager session ${result.sessionID} and removed it from Agent Manager.`, + metadata: { action: "stop", sessionID: result.sessionID }, } } diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 992ad85fd1..813de72c5f 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -1,6 +1,6 @@ Inspect and orchestrate Agent Manager sessions, or start new sessions, in the VS Code extension. -Use `action: "list"` to inspect the compact Agent Manager overview and `action: "prompt"` to send one instruction to one existing managed session. List results include user-defined sections, ungrouped worktrees, and managed local sessions. Optional filters can narrow by section ID or by `idle`, `busy`, `retry`, `offline`, or `waiting` state. Prompting is targeted only: it does not broadcast, create a session, or wait for the target to finish. +Use `action: "list"` to inspect the compact Agent Manager overview, `action: "prompt"` to send one instruction to one existing managed session, and `action: "stop"` to stop and remove one managed session. List results include user-defined sections, ungrouped worktrees, and managed local sessions. Optional filters can narrow by section ID or by `idle`, `busy`, `retry`, `offline`, or `waiting` state. Prompting and stopping are targeted only: they do not broadcast or create sessions, and prompting does not wait for the target to finish. To start sessions, keep using the existing `mode` and `tasks` input without an action. Use start mode when the user explicitly asks you to fan out work into Agent Manager, create Agent Manager worktrees, or start multiple Agent Manager sessions for independent tasks. diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 177f9ebe86..9091fcb972 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -158,6 +158,8 @@ describe("agent_manager tool", () => { expect(schema.anyOf).toBeUndefined() expect(schema.oneOf).toBeUndefined() expect(schema.allOf).toBeUndefined() + const action = schema.properties?.action + expect(action && typeof action === "object" ? action.enum : undefined).toEqual(["list", "prompt", "stop"]) expect(Object.keys(schema.properties ?? {})).toEqual([ "mode", "versions", @@ -299,6 +301,50 @@ describe("agent_manager tool", () => { await rt.dispose() }) + test("stops one existing session with a separate mutation permission pattern", async () => { + const requests: unknown[] = [] + const rt = makeRuntime("test", { + request: (input) => + Effect.sync(() => { + requests.push(input) + return { operation: "stop" as const, sessionID: SessionID.make("ses_target"), stopped: true as const } + }), + }) + const tool = await rt.runPromise( + Effect.gen(function* () { + return yield* Tool.init(yield* AgentManagerTool) + }), + ) + const permissions: unknown[] = [] + const result = await rt.runPromise( + provideTmpdirInstance(() => + tool.execute( + { action: "stop", sessionID: SessionID.make("ses_target") }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => permissions.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(permissions).toEqual([ + { + permission: "agent_manager", + patterns: ["stop"], + always: ["stop"], + metadata: { action: "stop", sessionID: "ses_target" }, + }, + ]) + expect(requests).toEqual([ + { + operation: "stop", + sessionID: ctx.sessionID, + targetSessionID: "ses_target", + }, + ]) + expect(result.output).toContain("removed it from Agent Manager") + expect(result.metadata).toEqual(expect.objectContaining({ action: "stop", sessionID: "ses_target" })) + await rt.dispose() + }) + test("inherits the latest invoking model and variant when omitted", async () => { const task = await publish(runtime, { prompt: "Fix" }, [ message("msg_current", "kilo", "kilo/shared", "low", 2), diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts index 82735eb14d..859d0a92bb 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import type { Session } from "@kilocode/sdk/v2" import { failed, + formatCost, formatRate, groupModelsByProvider, isSessionTreeMember, @@ -85,4 +86,12 @@ describe("TUI model usage", () => { ]) expect(formatRate({ input: 100, output: 0, reasoning: 0, cache: { read: 300, write: 100 } })).toBe("60.0%") }) + + test("formats costs to cents", () => { + expect(formatCost(18.382407)).toBe("$18.38") + expect(formatCost(12.166524)).toBe("$12.17") + expect(formatCost(0.0000001)).toBe("$0.00") + expect(formatCost(-1)).toBe("$0.00") + expect(formatCost(Number.NaN)).toBe("$0.00") + }) }) diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/sidebar-usage-layout.test.tsx b/packages/opencode/test/kilocode/cli/cmd/tui/sidebar-usage-layout.test.tsx new file mode 100644 index 0000000000..c97ddf7b07 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/tui/sidebar-usage-layout.test.tsx @@ -0,0 +1,33 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { ModelRow, UsageRow } from "@/kilocode/plugins/sidebar-usage-row" + +test("model costs align with usage values", async () => { + const app = await testRender( + () => ( + + + {}} + /> + + ), + { width: 36, height: 3 }, + ) + + try { + await app.renderOnce() + const lines = app.captureCharFrame().split("\n") + expect(lines[0]!.lastIndexOf("4")).toBe(lines[1]!.lastIndexOf("0")) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts b/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts new file mode 100644 index 0000000000..dc12a9e8f0 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test" +import { RemoteExitRpc } from "../../../../src/kilocode/cli/cmd/tui/remote-exit-rpc" +import { createParentRemoteExitBridge } from "../../../../src/kilocode/cli/cmd/tui/remote-exit-bridge" +import { Rpc } from "../../../../src/util/rpc" + +describe("parent remote exit RPC bridge", () => { + test("subscribes before readiness and invokes the idempotent local Exit", async () => { + const order: string[] = [] + let handler: (() => void) | undefined + const client = { + on(event: string, next: () => void) { + expect(event).toBe(RemoteExitRpc.Event) + order.push("subscribe") + handler = next + return () => { + order.push("unsubscribe") + handler = undefined + } + }, + async call(method: string) { + order.push(method) + }, + } + let exits = 0 + const exit = () => { + exits += 1 + } + + const bridge = createParentRemoteExitBridge(client, exit) + await bridge.ready() + expect(order).toEqual(["subscribe", "tuiReady"]) + + handler?.() + handler?.() + await Promise.resolve() + expect(exits).toBe(1) + + await bridge.dispose() + expect(order).toEqual(["subscribe", "tuiReady", "tuiGone", "unsubscribe"]) + expect(handler).toBeUndefined() + }) + + test("cleanup unregisters the worker even when readiness fails", async () => { + const calls: string[] = [] + const bridge = createParentRemoteExitBridge( + { + on() { + calls.push("subscribe") + return () => calls.push("unsubscribe") + }, + async call(method: string) { + calls.push(method) + if (method === "tuiReady") throw new Error("worker unavailable") + }, + }, + () => {}, + ) + + await expect(bridge.ready()).rejects.toThrow("worker unavailable") + await bridge.dispose() + expect(calls).toEqual(["subscribe", "tuiReady", "tuiGone", "unsubscribe"]) + }) + + test("unsubscribes in finally when tuiGone fails", async () => { + const calls: string[] = [] + let subscribed = true + const bridge = createParentRemoteExitBridge( + { + on() { + calls.push("subscribe") + return () => { + calls.push("unsubscribe") + subscribed = false + } + }, + async call(method: string) { + calls.push(method) + if (method === "tuiGone") throw new Error("worker gone failed") + }, + }, + () => {}, + ) + + await bridge.ready() + await expect(bridge.dispose()).rejects.toThrow("worker gone failed") + expect(calls).toEqual(["subscribe", "tuiReady", "tuiGone", "unsubscribe"]) + expect(subscribed).toBe(false) + }) + + test("bounds unresolved tuiGone and still unsubscribes", async () => { + let subscribed = true + const bridge = createParentRemoteExitBridge( + { + on() { + return () => { + subscribed = false + } + }, + async call(method: string) { + if (method === "tuiGone") await new Promise(() => {}) + }, + }, + () => {}, + ) + + await bridge.ready() + const disposing = bridge.dispose(5).catch((error: unknown) => error) + await Bun.sleep(20) + + expect(subscribed).toBe(false) + expect(await disposing).toBeInstanceOf(Error) + }) + + test("consumes a serialized worker RPC event without shared backend state", async () => { + let exits = 0 + const target = { + postMessage() {}, + onmessage: null as ((this: Worker, event: MessageEvent) => unknown) | null, + } + const client = Rpc.client<{ tuiReady: () => void; tuiGone: () => void }>(target) + const bridge = createParentRemoteExitBridge(client, () => void (exits += 1)) + + target.onmessage?.call( + target as unknown as Worker, + new MessageEvent("message", { + data: JSON.stringify({ type: "rpc.event", event: RemoteExitRpc.Event, data: undefined }), + }), + ) + await Promise.resolve() + + expect(exits).toBe(1) + void bridge + }) +}) diff --git a/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts b/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts new file mode 100644 index 0000000000..463789c731 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { RemoteExit } from "../../../../src/kilo-sessions/remote-exit" +import { RemoteExitRpc } from "../../../../src/kilocode/cli/cmd/tui/remote-exit-rpc" +import { createWorkerRemoteExit } from "../../../../src/kilocode/cli/cmd/tui/remote-exit-worker" + +describe("worker remote exit lifecycle", () => { + test("registers only after tuiReady and emits RPC instead of invoking parent state", async () => { + const events: Array<{ event: string; data: unknown }> = [] + const lifecycle = createWorkerRemoteExit((event, data) => { + events.push({ event, data }) + }) + + expect(RemoteExit.get()).toBeUndefined() + lifecycle.ready() + expect(RemoteExit.get()).toBeDefined() + + await RemoteExit.get()?.() + expect(events).toEqual([{ event: RemoteExitRpc.Event, data: undefined }]) + lifecycle.shutdown() + expect(RemoteExit.get()).toBeUndefined() + }) + + test("tuiGone and shutdown remove only their owned registration", () => { + const lifecycle = createWorkerRemoteExit(() => {}) + lifecycle.ready() + const replacement = async () => {} + const unregisterReplacement = RemoteExit.register(replacement) + + lifecycle.gone() + expect(RemoteExit.get()).toBe(replacement) + + lifecycle.ready() + lifecycle.shutdown() + expect(RemoteExit.get()).toBeUndefined() + unregisterReplacement() + }) +}) diff --git a/packages/opencode/test/kilocode/cli/tui/thread.test.ts b/packages/opencode/test/kilocode/cli/tui/thread.test.ts index d484707e1f..fd8c040e5c 100644 --- a/packages/opencode/test/kilocode/cli/tui/thread.test.ts +++ b/packages/opencode/test/kilocode/cli/tui/thread.test.ts @@ -2,7 +2,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { tmpdir } from "../../../fixture/fixture" -import { resolveThreadDirectory } from "../../../../src/cli/cmd/tui" +import { + embeddedRemoteExitClient, + resolveThreadDirectory, + runEmbeddedRemoteExitBridge, +} from "../../../../src/cli/cmd/tui" import { KiloTuiThreadDaemon } from "../../../../src/kilocode/cli/cmd/tui/thread" import { DaemonClient } from "../../../../src/kilocode/daemon/client" @@ -35,6 +39,47 @@ describe("kilo tui thread", () => { } }) + test("enables remote exit only for the embedded worker transport", () => { + const client = { marker: "worker" } + + expect(embeddedRemoteExitClient(false, client)).toBe(client) + expect(embeddedRemoteExitClient(true, client)).toBeUndefined() + expect(embeddedRemoteExitClient(false, undefined)).toBeUndefined() + }) + + test("continues TUI startup when remote-exit readiness and cleanup never reply", async () => { + const calls: string[] = [] + let handler: (() => void) | undefined + let tuiContinued = false + const done = Promise.resolve().then(() => { + tuiContinued = true + }) + + await runEmbeddedRemoteExitBridge({ + client: { + on(_event, next) { + calls.push("subscribe") + handler = next + return () => { + calls.push("unsubscribe") + handler = undefined + } + }, + async call(method) { + calls.push(method) + await new Promise(() => {}) + }, + }, + exit: () => {}, + done, + timeoutMs: 5, + }) + + expect(tuiContinued).toBe(true) + expect(calls).toEqual(["subscribe", "tuiReady", "tuiGone", "unsubscribe"]) + expect(handler).toBeUndefined() + }) + test("validates imported daemon session over HTTP after importing from cloud", async () => { await using root = await tmpdir() const cloud = "ses_cloud" diff --git a/packages/opencode/test/kilocode/permission/agent-manager-prompt.test.ts b/packages/opencode/test/kilocode/permission/agent-manager-prompt.test.ts index 79baad81f2..da18967eb2 100644 --- a/packages/opencode/test/kilocode/permission/agent-manager-prompt.test.ts +++ b/packages/opencode/test/kilocode/permission/agent-manager-prompt.test.ts @@ -3,9 +3,10 @@ import { Permission } from "../../../src/permission" const broad = Permission.fromConfig({ agent_manager: "allow" }) -describe("Agent Manager prompt permissions", () => { +describe("Agent Manager side-effect permissions", () => { test("requires consent despite a broad Agent Manager allow rule", () => { expect(Permission.resolve("agent_manager", "prompt", broad).action).toBe("ask") + expect(Permission.resolve("agent_manager", "stop", broad).action).toBe("ask") expect(Permission.resolve("agent_manager", "local", broad).action).toBe("allow") expect(Permission.resolve("agent_manager", "worktree", broad).action).toBe("allow") }) @@ -13,16 +14,19 @@ describe("Agent Manager prompt permissions", () => { test("requires consent despite a global allow rule", () => { const rules = [{ permission: "*", pattern: "*", action: "allow" as const }] expect(Permission.resolve("agent_manager", "prompt", rules).action).toBe("ask") + expect(Permission.resolve("agent_manager", "stop", rules).action).toBe("ask") }) test("requires consent despite a saved wildcard approval", () => { const rules = Permission.fromConfig({ agent_manager: "ask" }) const saved = [{ permission: "agent_manager", pattern: "*", action: "allow" as const }] expect(Permission.resolve("agent_manager", "prompt", rules, saved).action).toBe("ask") + expect(Permission.resolve("agent_manager", "stop", rules, saved).action).toBe("ask") }) - test("allows only an explicit prompt approval", () => { - const rules = Permission.fromConfig({ agent_manager: { prompt: "allow" } }) + test("allows only explicit side-effect approvals", () => { + const rules = Permission.fromConfig({ agent_manager: { prompt: "allow", stop: "allow" } }) expect(Permission.resolve("agent_manager", "prompt", rules).action).toBe("allow") + expect(Permission.resolve("agent_manager", "stop", rules).action).toBe("allow") }) }) diff --git a/packages/opencode/test/kilocode/sessions/attached-state.test.ts b/packages/opencode/test/kilocode/sessions/attached-state.test.ts new file mode 100644 index 0000000000..676ba79b8c --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/attached-state.test.ts @@ -0,0 +1,747 @@ +import { describe, expect, test } from "bun:test" +import { AttachedState } from "../../../src/kilo-sessions/attached-state" + +const nolog = { warn: () => {} } + +function key(ids: Iterable) { + // Mirrors the collision-safe keyOf used by the implementation under test. + return [...ids] + .sort() + .map((id) => `${id.length}:${id}`) + .join(",") +} + +describe("AttachedState", () => { + test("announce adds the id to the union and fires heartbeat once", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + + await state.announce("ses_new") + + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_new"]) + }) + + test("announce is a no-op when the id is already in the union (presence-owned)", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + // setPresence is the only thing that fires a heartbeat in this test; it + // is the one call the assertion below counts. + state.setPresence(["ses_existing"]) + const heartbeatsAfterPresence = heartbeatCalls + + await state.announce("ses_existing") + + // No new heartbeat — the announce short-circuited because the id was + // already owned by presence. + expect(heartbeatCalls).toBe(heartbeatsAfterPresence) + expect([...state.union()].sort()).toEqual(["ses_existing"]) + }) + + test("announce is a no-op when the same id is announced twice and avoids an extra heartbeat", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + + await state.announce("ses_dup") + await state.announce("ses_dup") + await state.announce("ses_dup") + + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_dup"]) + }) + + test("presence adoption removes the id from the pending set without an extra heartbeat", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + + await state.announce("ses_adopt") + // announce heartbeat fired once + expect(heartbeatCalls).toBe(1) + + // Presence reports the same id — it should be adopted and dropped from + // pending; the union key is unchanged so no second heartbeat is required. + state.setPresence(["ses_adopt"]) + + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_adopt"]) + }) + + test("heartbeat failure on announce rolls back only the pending entry, never a presence-owned id", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.reject(new Error("private relay detail: credential=must-not-leak")) + }, + log: nolog, + }) + + // Presence owns "ses_owned" first; setPresence fires the first heartbeat. + state.setPresence(["ses_owned"]) + const heartbeatsAfterPresence = heartbeatCalls + + // Now announce "ses_new" — heartbeat throws. The rollback must NOT touch + // presence-owned "ses_owned". + await expect(state.announce("ses_new")).rejects.toThrow("private relay detail") + + expect(heartbeatCalls).toBe(heartbeatsAfterPresence + 1) + expect([...state.union()].sort()).toEqual(["ses_owned"]) + }) + + test("concurrent setPresence during an in-flight announce retains the announced id", async () => { + let resolveHeartbeat: (() => void) | undefined + const heartbeatStarted = new Promise((resolve) => { + resolveHeartbeat = resolve + }) + const heartbeatDone = Promise.withResolvers() + const calls: { announce: number; presence: number } = { announce: 0, presence: 0 } + const state = AttachedState.create({ + // The same factory heartbeat is used by both paths; the slow path is the + // announce (waits on heartbeatDone) and the fast path is setPresence. + heartbeat: () => { + // The announce call is the first one we expect; subsequent calls are + // from setPresence while the announce is still in flight. Record + // arrival order so the assertion can prove the announce happened + // before setPresence. + calls[calls.announce === 0 ? "announce" : "presence"] += 1 + resolveHeartbeat!() + return heartbeatDone.promise + }, + log: nolog, + }) + + // Start the announce but do not await yet. + const announcePromise = state.announce("ses_new") + await heartbeatStarted + // Concurrent presence replacement while the announce heartbeat is in flight. + // setPresence must NOT drop the pending "ses_new". + state.setPresence(["ses_other"]) + heartbeatDone.resolve() + await announcePromise + + expect(calls.announce).toBe(1) + expect(calls.presence).toBe(1) + // The announced id survived the concurrent presence replacement and is + // still in the union alongside the presence-owned id. + expect([...state.union()].sort()).toEqual(["ses_new", "ses_other"]) + }) + + test("setPresence fires heartbeat only when the union actually changes", () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + + state.setPresence(["ses_a", "ses_b"]) + expect(heartbeatCalls).toBe(1) + + // Same set — no heartbeat. + state.setPresence(["ses_b", "ses_a"]) + expect(heartbeatCalls).toBe(1) + + // Changed union — heartbeat. + state.setPresence(["ses_a"]) + expect(heartbeatCalls).toBe(2) + }) + + test("reset clears both presence and pending and a subsequent setPresence fires heartbeat again", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + state.setPresence(["ses_a"]) + await state.announce("ses_b") + expect(heartbeatCalls).toBe(2) + expect([...state.union()].sort()).toEqual(["ses_a", "ses_b"]) + + state.reset() + expect([...state.union()]).toEqual([]) + + // A fresh presence replacement after reset must fire heartbeat because the + // baseline union key is empty. + state.setPresence(["ses_c"]) + expect(heartbeatCalls).toBe(3) + expect([...state.union()].sort()).toEqual(["ses_c"]) + }) + + test("union key remains stable for the same set of ids regardless of insertion order", () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + state.setPresence(["a", "b", "c"]) + expect(heartbeatCalls).toBe(1) + state.setPresence(["c", "b", "a"]) + expect(heartbeatCalls).toBe(1) + }) + + test("announce after the same id was rolled back re-attaches it", async () => { + let attempts = 0 + const state = AttachedState.create({ + heartbeat: () => { + attempts += 1 + if (attempts === 1) return Promise.reject(new Error("transient")) + return Promise.resolve() + }, + log: nolog, + }) + + await expect(state.announce("ses_retry")).rejects.toThrow("transient") + expect([...state.union()]).toEqual([]) + + await state.announce("ses_retry") + expect([...state.union()].sort()).toEqual(["ses_retry"]) + }) + + test("warn log captures heartbeat failures from setPresence without surfacing them", () => { + const warnings: unknown[][] = [] + const state = AttachedState.create({ + heartbeat: () => Promise.reject(new Error("presence heartbeat down")), + log: { warn: (...args: unknown[]) => warnings.push(args) }, + }) + + state.setPresence(["ses_a"]) + + // setPresence fires heartbeat fire-and-forget; allow the microtask to drain. + return Promise.resolve().then(() => { + expect(warnings).toHaveLength(1) + expect(String(warnings[0]?.[0])).toContain("heartbeat") + const meta = warnings[0]?.[1] as { error?: unknown } | undefined + expect(String(meta?.error ?? "")).toContain("presence heartbeat down") + // Union still reflects presence even when heartbeat fails. + expect([...state.union()].sort()).toEqual(["ses_a"]) + // key helper sanity-check (not part of the contract, but useful for the + // reviewer to read the union key format). + expect(key(state.union())).toBe("5:ses_a") + }) + }) + + // Regression: lastKey was being updated BEFORE the awaited heartbeat, so a + // concurrent setPresence that adopted the same id would skip its heartbeat + // because the (false) cache said the relay already knew. When the announce + // then failed, the relay was left believing the old union. The fix is to + // only update the last-sent key on a successful heartbeat, and to let + // setPresence always fire when the current union diverges from it. + // Updated for the presence-adoption-shields-failure contract: when + // setPresence adopts the in-flight id, presence becomes authoritative + // and the old announce heartbeat rejection is suppressed (the attach + // is already successful from the caller's perspective). + test("setPresence adopts an in-flight pending id and still fires heartbeat when the announce later fails", async () => { + const announceHeartbeat = Promise.withResolvers() + + const calls: { announce: number; presence: number } = { announce: 0, presence: 0 } + let announcing = false + const state = AttachedState.create({ + heartbeat: () => { + if (announcing && calls.announce === 0) { + calls.announce += 1 + return announceHeartbeat.promise + } + calls.presence += 1 + return Promise.resolve() + }, + log: nolog, + }) + + // Existing presence-owned id, successfully heartbeated earlier. + state.setPresence(["ses_existing"]) + await Promise.resolve() + calls.announce = 0 + calls.presence = 0 + + // Announce "ses_new" — it will block until we resolve/reject the heartbeat. + announcing = true + const announcePromise = state.announce("ses_new") + await Promise.resolve() + + // Concurrent presence replacement adopts the in-flight pending id. + state.setPresence(["ses_existing", "ses_new"]) + await Promise.resolve() + + expect(calls.presence).toBe(1) + // The pending id was adopted by presence, so the announce's pending + // entry is gone before the announce heartbeat resolves. + expect([...state.union()].sort()).toEqual(["ses_existing", "ses_new"]) + + // Reject the announce heartbeat. With the adoption-shield contract, + // the announce resolves cleanly because presence owns the id. + announceHeartbeat.reject(new Error("announce failed: credential=must-not-leak")) + await announcePromise + + // Final local state reflects the presence set. + expect([...state.union()].sort()).toEqual(["ses_existing", "ses_new"]) + + // A subsequent setPresence with the same set must NOT fire another + // heartbeat (the relay already received {ses_existing, ses_new} from + // the setPresence call above). + calls.presence = 0 + state.setPresence(["ses_existing", "ses_new"]) + await Promise.resolve() + expect(calls.presence).toBe(0) + + // A subsequent setPresence that REMOVES ses_new must fire a heartbeat + // because the relay's last sent union was {ses_existing, ses_new} and + // the new union is {ses_existing}. + calls.presence = 0 + state.setPresence(["ses_existing"]) + await Promise.resolve() + expect(calls.presence).toBe(1) + }) + + test("announce rolls back and throws when the heartbeat rejects (no remote)", async () => { + const state = AttachedState.create({ + heartbeat: () => Promise.reject(new Error("no remote connection")), + log: nolog, + }) + + await expect(state.announce("ses_new")).rejects.toThrow("no remote connection") + // The id must not linger in the union after a failed announce. + expect([...state.union()]).toEqual([]) + }) + + // Concurrent announce(id) callers must share the same in-flight outcome + // and fire the heartbeat exactly once. Driven by published promises + // (no setTimeout / sleep) so the assertion is deterministic. + test("concurrent announce callers share one in-flight promise and fire one heartbeat", async () => { + const heartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return heartbeat.promise + }, + log: nolog, + }) + + // Both callers start before the heartbeat resolves. They must converge + // on a single in-flight promise and not race two heartbeats. + const a = state.announce("ses_concurrent") + const b = state.announce("ses_concurrent") + // Yield so both synchronous prefixes have run before we resolve. + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_concurrent"]) + + heartbeat.resolve() + await Promise.all([a, b]) + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_concurrent"]) + }) + + // If the shared heartbeat rejects, every concurrent caller observes the + // same error and the id is rolled back exactly once. + test("concurrent announce callers observe the same rejection and roll back once", async () => { + const heartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return heartbeat.promise + }, + log: nolog, + }) + + const a = state.announce("ses_shared_fail") + const b = state.announce("ses_shared_fail") + const c = state.announce("ses_shared_fail") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + heartbeat.reject(new Error("shared failure")) + const results = await Promise.allSettled([a, b, c]) + expect(results.every((r) => r.status === "rejected")).toBe(true) + // The id must not linger in the union after a shared failure. + expect([...state.union()]).toEqual([]) + }) + + // Presence adopting an in-flight announce must leave a coherent outcome: + // - the union reflects both presence and pending, + // - a later setPresence with the same union does NOT fire a redundant + // heartbeat (the relay already learned about it from setPresence's + // own fire-and-forget). + test("presence adoption during an in-flight announce keeps the union coherent and avoids a redundant heartbeat", async () => { + const announceHeartbeat = Promise.withResolvers() + const calls: { announce: number; presence: number } = { announce: 0, presence: 0 } + const state = AttachedState.create({ + heartbeat: () => { + if (calls.announce === 0) { + // First call is the announce (blocks on announceHeartbeat). + calls.announce += 1 + return announceHeartbeat.promise + } + // Subsequent calls are setPresence fire-and-forget. + calls.presence += 1 + return Promise.resolve() + }, + log: nolog, + }) + + const announcePromise = state.announce("ses_coexist") + await Promise.resolve() + // The announce reached its first `await`; the in-flight heartbeat is the + // announce heartbeat (not yet resolved). + expect(calls.announce).toBe(1) + expect(calls.presence).toBe(0) + + // Concurrent presence replacement that includes the pending id. + state.setPresence(["ses_coexist"]) + // Drain the setPresence fire-and-forget heartbeat microtask. + await Promise.resolve() + // setPresence fired its own heartbeat. + expect(calls.presence).toBe(1) + // The pending id is now in the union via presence; the pending entry + // is gone so the announce's success path leaves the state consistent. + expect([...state.union()].sort()).toEqual(["ses_coexist"]) + + // Resolve the in-flight announce heartbeat. It must NOT fire another + // heartbeat of its own (the contract is "exactly one per attach"). + announceHeartbeat.resolve() + await announcePromise + expect(calls.presence).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_coexist"]) + }) + + // Gap 3: presence adopts an in-flight announce, then the OLD announce + // heartbeat rejects. The attach is successful because presence is the + // authoritative owner — the announce caller must resolve cleanly, the + // pending entry must stay clean (already deleted by setPresence), and + // the in-flight map must not retain a stale entry. + test("presence adoption during an in-flight announce shields a later heartbeat failure", async () => { + const announceHeartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return announceHeartbeat.promise + }, + log: nolog, + }) + + // Start the announce; it blocks on the heartbeat. + const announcePromise = state.announce("ses_shield") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + // Presence adopts the in-flight id and fires its own heartbeat. + state.setPresence(["ses_shield"]) + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + // Presence adopted; the pending entry was already cleared. + expect([...state.union()].sort()).toEqual(["ses_shield"]) + + // Now the OLD announce heartbeat rejects. The attach was successful + // (presence owns the id), so the announce caller must NOT see this + // as an attach failure. + announceHeartbeat.reject(new Error("late relay failure")) + await expect(announcePromise).resolves.toBeUndefined() + // Presence is still authoritative; the union still reflects the id. + expect([...state.union()].sort()).toEqual(["ses_shield"]) + + // A subsequent announce for the same id must short-circuit on the + // presence check, NOT start a new heartbeat (no stale inflight entry). + const followup = state.announce("ses_shield") + await followup + expect(heartbeatCalls).toBe(2) + }) + + // Truly unadopted announce heartbeat failure must still reject and roll + // back. Pair with the shield test above to prove both paths behave + // coherently. + test("unadopted heartbeat failure still rejects and rolls back", async () => { + let resolve: () => void = () => {} + let reject: (reason: unknown) => void = () => {} + const make = () => + new Promise((res, rej) => { + resolve = res + reject = rej + }) + let current = make() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return current + }, + log: nolog, + }) + + const announcePromise = state.announce("ses_unadopted") + // Mark the announce's eventual rejection as handled so the unhandled + // rejection tracker does not stall the test. expect.rejects.toThrow + // only attaches a handler when awaited; the explicit catch keeps the + // runtime happy between the two await points. + announcePromise.catch(() => {}) + + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + // Presence does NOT adopt the id — its set is empty. + reject(new Error("relay down")) + await expect(announcePromise).rejects.toThrow("relay down") + expect([...state.union()]).toEqual([]) + + // A fresh announce must start a new heartbeat; no stale inflight entry. + current = make() + const second = state.announce("ses_unadopted") + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + resolve() + await second + expect([...state.union()].sort()).toEqual(["ses_unadopted"]) + }) + + // Gap 2 (lifecycle): after a settled in-flight entry, a later announce + // for the same id must NOT be pinned to the old promise. With the + // map design, the owner clears its entry in `finally` only + // if the map still points to its own promise. A new announce can + // therefore start a fresh heartbeat, and a stale promise never blocks + // later traffic. + test("a settled in-flight entry is cleared and does not pin later announces", async () => { + let resolve: () => void = () => {} + let reject: (reason: unknown) => void = () => {} + const make = () => + new Promise((res, rej) => { + resolve = res + reject = rej + }) + let current = make() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return current + }, + log: nolog, + }) + + // First announce runs to completion. After it settles, its inflight + // entry must be gone (the owner finally clears it). + const a = state.announce("ses_pinned") + await Promise.resolve() + resolve() + await a + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_pinned"]) + + // The id is still in pending (presence has not adopted it), so a + // second announce short-circuits via the pending check without + // starting a new heartbeat. The inflight map is empty; the + // settled promise is not pinned. + const b = state.announce("ses_pinned") + await b + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_pinned"]) + + // Drop the id from the union (reset) and announce again. The new + // announce must take a fresh heartbeat path with no stale pin. + state.reset() + current = make() + const c = state.announce("ses_pinned") + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + resolve() + await c + expect([...state.union()].sort()).toEqual(["ses_pinned"]) + }) + + // reset() must drop every in-flight entry so new announces after reset + // can start fresh heartbeats. + test("reset drops in-flight entries and a follow-up announce starts fresh", async () => { + let resolve: () => void = () => {} + let reject: (reason: unknown) => void = () => {} + const make = () => + new Promise((res, rej) => { + resolve = res + reject = rej + }) + let current = make() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return current + }, + log: nolog, + }) + + // Start an announce, then reset before it settles. + const a = state.announce("ses_reset") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + state.reset() + // The map was cleared by reset; resolving the old promise is a no-op + // for any future caller because the entry is gone. + resolve() + await a + expect([...state.union()]).toEqual([]) + + // After reset, a fresh announce must start a new heartbeat (the + // cleared inflight entry does not block it). + current = make() + const b = state.announce("ses_reset") + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + resolve() + await b + expect([...state.union()].sort()).toEqual(["ses_reset"]) + }) + + // Collision regression: two distinct id sets must never produce the + // same union key. The historical "|" separator was unsafe — sets + // ["a", "b"] and ["a|b"] both encoded to the same key, which let + // setPresence skip a heartbeat it should have fired. The fix length- + // prefixes each id; this test pins the observable consequence + // (the two distinct sets fire two heartbeats). + test("union key is collision-safe across distinct id sets containing the historical separator", () => { + let calls = 0 + const state = AttachedState.create({ + heartbeat: () => { + calls += 1 + return Promise.resolve() + }, + log: nolog, + }) + state.setPresence(["a", "b"]) + state.setPresence(["a|b"]) + // With the old key, the second setPresence would have been a no-op + // because the keys collided. With the collision-safe key it must fire. + expect(calls).toBe(2) + expect([...state.union()]).toEqual(["a|b"]) + }) + + // Gap 4 (lifecycle): after reset(), a late success from an in-flight + // announce started before the reset must not overwrite lastSentKey. + // Without the generation guard, the old promise's + // `lastSentKey = keyOf(union())` writes a value derived from the new + // generation's union, causing redundant or stale change detection in + // subsequent setPresence calls. The old completion must be a no-op + // for lastSentKey; the new lifecycle's setPresence calls drive the + // baseline. + test("stale announce completion after reset does not overwrite lastSentKey", async () => { + const staleHeartbeat = Promise.withResolvers() + const presenceHeartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + if (heartbeatCalls === 1) return staleHeartbeat.promise + return presenceHeartbeat.promise + }, + log: nolog, + }) + + // Start the stale announce; it blocks on the controlled heartbeat. + const stale = state.announce("ses_stale") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + // Reset clears state and the in-flight map; the stale announce's + // captured generation no longer matches the current one. + state.reset() + + // Fresh presence replacement after reset fires a new heartbeat. + state.setPresence(["ses_fresh"]) + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + + // Resolve the stale heartbeat. The stale announce's success path must + // NOT overwrite lastSentKey (the new lifecycle's baseline). + staleHeartbeat.resolve() + await stale + + // Resolve the presence heartbeat so it does not stall the test. + presenceHeartbeat.resolve() + + // Union reflects the new presence set; the stale id is not added back. + expect([...state.union()].sort()).toEqual(["ses_fresh"]) + + // Repeat the same setPresence; it must NOT fire a redundant heartbeat + // because lastSentKey is still accurate. + const heartbeatsBeforeRepeat = heartbeatCalls + state.setPresence(["ses_fresh"]) + expect(heartbeatCalls).toBe(heartbeatsBeforeRepeat) + expect([...state.union()].sort()).toEqual(["ses_fresh"]) + }) + + // Gap 4 (lifecycle, replacement): after reset(), a late completion from + // an in-flight announce started before the reset must not clobber a + // replacement in-flight entry installed after the reset. The existing + // `finally` guard (`inflight.get(id) === owned`) already handles the + // in-flight map; this test pins the contract end-to-end so a future + // refactor cannot accidentally drop the replacement entry when an old + // promise settles. + test("stale announce completion after reset does not clobber a replacement in-flight entry", async () => { + const staleHeartbeat = Promise.withResolvers() + const replacementHeartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + if (heartbeatCalls === 1) return staleHeartbeat.promise + return replacementHeartbeat.promise + }, + log: nolog, + }) + + // Start the stale announce; it blocks on the controlled heartbeat. + const stale = state.announce("ses_x") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + // Reset clears state and the in-flight map. + state.reset() + + // Install a replacement in-flight entry for the same id. + const replacement = state.announce("ses_x") + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + + // Resolve the stale heartbeat. The stale announce's finally must NOT + // delete the replacement in-flight entry; the map still points to + // the replacement's Promise, not the stale one. + staleHeartbeat.resolve() + await stale + + // The replacement in-flight entry is still in the map. Resolve the + // replacement heartbeat and verify it settles cleanly. + replacementHeartbeat.resolve() + await replacement + expect([...state.union()].sort()).toEqual(["ses_x"]) + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/remote-command.test.ts b/packages/opencode/test/kilocode/sessions/remote-command.test.ts new file mode 100644 index 0000000000..8eeeae2c59 --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/remote-command.test.ts @@ -0,0 +1,755 @@ +import { describe, expect, test } from "bun:test" +import { RemoteCommand } from "../../../src/kilo-sessions/remote-command" +import { RemoteExit } from "../../../src/kilo-sessions/remote-exit" +import type { Info as SessionInfo } from "../../../src/session/session" +import { MessageV2 } from "../../../src/session/message-v2" +import { MessageID, SessionID } from "../../../src/session/schema" + +describe("RemoteCommand", () => { + test("validates list command protocol requests", () => { + expect(RemoteCommand.ListRequest.safeParse({ protocolVersion: 1 }).success).toBe(true) + expect(RemoteCommand.ListRequest.safeParse({ protocolVersion: 2 }).success).toBe(false) + expect(RemoteCommand.ListRequest.safeParse({ protocolVersion: 1, extra: true }).success).toBe(false) + }) + + test("validates strict exit CLI requests", () => { + expect(RemoteCommand.ExitRequest.safeParse({ protocolVersion: 1 }).success).toBe(true) + expect(RemoteCommand.ExitRequest.safeParse({}).success).toBe(false) + expect(RemoteCommand.ExitRequest.safeParse({ protocolVersion: 2 }).success).toBe(false) + expect(RemoteCommand.ExitRequest.safeParse({ protocolVersion: 1, extra: true }).success).toBe(false) + }) + + test("validates structured command requests without duplicating session identity", () => { + const valid = { + protocolVersion: 1 as const, + command: "review", + arguments: " main\nkeep spacing ", + messageID: "msg_remote", + model: { providerID: "kilo", modelID: "anthropic/claude-sonnet-4" }, + variant: "high", + } + + expect(RemoteCommand.SendRequest.parse(valid)).toEqual(valid) + expect(RemoteCommand.SendRequest.safeParse({ ...valid, protocolVersion: 2 }).success).toBe(false) + expect(RemoteCommand.SendRequest.safeParse({ ...valid, sessionID: "ses_duplicate" }).success).toBe(false) + expect(RemoteCommand.SendRequest.safeParse({ ...valid, messageID: "bad" }).success).toBe(false) + expect( + RemoteCommand.SendRequest.safeParse({ ...valid, arguments: "x".repeat(RemoteCommand.MAX_ARGUMENTS_LENGTH + 1) }) + .success, + ).toBe(false) + expect( + RemoteCommand.SendRequest.safeParse({ ...valid, messageID: "msg" + "x".repeat(RemoteCommand.MAX_STRING_LENGTH) }) + .success, + ).toBe(false) + }) + + test("builds an allowlisted command catalog", () => { + const catalog = RemoteCommand.build([ + { + name: "review", + description: "Review changes", + agent: "reviewer", + model: "kilo/review-model", + source: "command", + hints: ["$ARGUMENTS"], + subtask: true, + template: "must-not-leak", + }, + { + name: "alpha", + description: "MCP prompt", + source: "mcp", + hints: ["$1"], + get template(): string { + throw new Error("template must not be read") + }, + }, + { + name: "secret-skill", + description: "Skill", + source: "skill", + hints: [], + template: "must-not-leak", + }, + { + name: "review", + description: "Duplicate", + source: "mcp", + hints: [], + template: "must-not-leak", + }, + ]) + + expect(catalog).toEqual({ + protocolVersion: 1, + commands: [ + { + name: "alpha", + description: "MCP prompt", + source: "mcp", + hints: ["$1"], + }, + { + name: "compact", + description: "compact the current session context", + hints: [], + }, + { + name: "review", + description: "Review changes", + agent: "reviewer", + model: "kilo/review-model", + source: "command", + hints: ["$ARGUMENTS"], + subtask: true, + }, + ], + }) + expect(JSON.stringify(catalog)).not.toContain("template") + expect(JSON.stringify(catalog)).not.toContain("secret-skill") + }) + + test("advertises only canonical exit while the embedded worker callback is registered", () => { + const base = RemoteCommand.build([ + { name: "beta", source: "command", hints: [], template: "beta" }, + { name: "alpha", source: "command", hints: [], template: "alpha" }, + ]) + expect(base.commands.map((item) => item.name)).toEqual(["alpha", "beta", "compact"]) + + const catalog = RemoteCommand.build( + [ + ...base.commands.map((item) => ({ ...item, template: item.name })), + { name: "exit", source: "command", hints: ["must-not-leak"], template: "must-not-run" }, + ], + true, + ) + + expect(catalog.commands).toEqual([ + { name: "alpha", source: "command", hints: [] }, + { name: "beta", source: "command", hints: [] }, + { + name: "compact", + description: "compact the current session context", + hints: [], + }, + { + name: "exit", + description: "Exit the CLI", + hints: [], + }, + ]) + expect(catalog.commands.some((item) => item.name === "quit" || item.name === "q")).toBe(false) + + expect(RemoteCommand.build([]).commands.map((item) => item.name)).toEqual(["compact"]) + }) + + test("catalog omits exit until the embedded worker TUI is ready", async () => { + const remote = RemoteCommand.create({ + exitAvailable: () => !!RemoteExit.get(), + list: async () => [], + command: async () => {}, + session: { get: async () => null as never, messages: async () => [] }, + agent: { default: async () => "test" }, + provider: { default: async () => ({ providerID: "test", modelID: "test" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + expect((await remote.list()).commands.some((item) => item.name === "exit")).toBe(false) + + const unregister = RemoteExit.register(async () => {}) + try { + expect((await remote.list()).commands.some((item) => item.name === "exit")).toBe(true) + } finally { + unregister() + } + + expect((await remote.list()).commands.some((item) => item.name === "exit")).toBe(false) + }) + + test("keeps compact and exit within command and byte caps", () => { + const commands = Array.from({ length: RemoteCommand.MAX_COMMANDS + 10 }, (_, index) => ({ + name: `command-${String(index).padStart(3, "0")}`, + description: "x".repeat(1_900), + source: "command" as const, + hints: [], + template: "hidden", + })) + const catalog = RemoteCommand.build(commands, true) + + expect(catalog.commands).toHaveLength(RemoteCommand.MAX_COMMANDS) + expect(catalog.commands.some((item) => item.name === "compact")).toBe(true) + expect(catalog.commands.some((item) => item.name === "exit")).toBe(true) + expect(new TextEncoder().encode(JSON.stringify(catalog)).byteLength).toBeLessThanOrEqual( + RemoteCommand.MAX_RESULT_BYTES, + ) + }) + + test("truncates catalogs over the command limit", () => { + const commands = Array.from({ length: RemoteCommand.MAX_COMMANDS + 10 }, (_, index) => ({ + name: `command-${String(index).padStart(3, "0")}`, + source: "command" as const, + hints: [], + template: "hidden", + })) + + const catalog = RemoteCommand.build(commands) + expect(catalog.commands).toHaveLength(RemoteCommand.MAX_COMMANDS) + expect(catalog.commands[0]?.name).toBe("command-000") + expect(catalog.commands.some((item) => item.name === "compact")).toBe(true) + }) + + test("skips entries over the per-field caps instead of failing the catalog", () => { + const catalog = RemoteCommand.build([ + { + name: "x".repeat(RemoteCommand.MAX_STRING_LENGTH + 1), + source: "command", + hints: [], + template: "hidden", + }, + { + name: "too-many-hints", + source: "command", + hints: Array.from({ length: RemoteCommand.MAX_HINTS + 1 }, () => "$ARGUMENTS"), + template: "hidden", + }, + { + name: "review", + source: "command", + hints: ["$ARGUMENTS"], + template: "hidden", + }, + ]) + + expect(catalog.commands.map((item) => item.name)).toEqual(["compact", "review"]) + }) + + test("truncates to the serialized catalog limit measured in UTF-8 bytes", () => { + const commands = Array.from({ length: RemoteCommand.MAX_COMMANDS - 1 }, (_, index) => ({ + name: `command-${index}`, + description: "🧪".repeat(900), + source: "command" as const, + hints: [], + template: "hidden", + })) + + const catalog = RemoteCommand.build(commands) + expect(catalog.commands.length).toBeGreaterThan(0) + expect(catalog.commands.length).toBeLessThan(commands.length) + expect(catalog.commands.some((item) => item.name === "compact")).toBe(true) + expect(new TextEncoder().encode(JSON.stringify(catalog)).byteLength).toBeLessThanOrEqual( + RemoteCommand.MAX_RESULT_BYTES, + ) + }) + + test("executes registered commands with verbatim structured input", async () => { + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "unexpected-agent" }, + provider: { default: async () => ({ providerID: "unexpected", modelID: "unexpected" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "review", + arguments: " main\nkeep spacing ", + messageID: "msg_remote", + model: { providerID: "custom:edge", modelID: "deployment/model" }, + variant: "high", + catalog: { protocolVersion: 1, commands: [{ name: "review", hints: [] }] }, + }) + + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_remote"), + command: "review", + arguments: " main\nkeep spacing ", + messageID: "msg_remote", + model: "custom:edge/deployment/model", + variant: "high", + }, + ]) + }) + + test("lets a registered compact command shadow the built-in", async () => { + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list: async () => [{ name: "compact", source: "command", hints: [], template: "custom compact" }], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "unexpected-agent" }, + provider: { default: async () => ({ providerID: "unexpected", modelID: "unexpected" }) }, + revert: { cleanup: async () => {} }, + compaction: { + create: async () => { + throw new Error("unexpected built-in compaction") + }, + }, + prompt: { loop: async () => {} }, + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + catalog: { protocolVersion: 1, commands: [{ name: "compact", source: "command", hints: [] }] }, + }) + + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_remote"), + command: "compact", + arguments: "", + }, + ]) + }) + + test("a registered compact in the preflight catalog shadows the built-in", async () => { + const calls: unknown[] = [] + const steps: unknown[] = [] + const session = { + id: SessionID.make("ses_remote"), + agent: "session-agent", + model: { providerID: "session-provider", id: "session-model" }, + } as SessionInfo + const remote = RemoteCommand.create({ + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + steps.push("get") + return session + }, + messages: async () => { + steps.push("messages") + return [] + }, + }, + agent: { default: async () => "unexpected-agent" }, + provider: { default: async () => ({ providerID: "unexpected", modelID: "unexpected" }) }, + revert: { + cleanup: async () => { + steps.push("cleanup") + }, + }, + compaction: { + create: async () => { + steps.push("create") + }, + }, + prompt: { + loop: async () => { + steps.push("loop") + }, + }, + }) + + const catalog = RemoteCommand.build([ + { + name: "compact", + source: "command", + hints: [], + template: "custom compact", + }, + ]) + expect(catalog.commands.some((item) => item.name === "compact")).toBe(true) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + catalog, + }) + + // No compact path steps were taken — the registered command was invoked instead. + expect(steps).toEqual([]) + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_remote"), + command: "compact", + arguments: "", + }, + ]) + }) + + test("a built-in compact in the preflight catalog falls back to the built-in path", async () => { + const steps: unknown[] = [] + const session = { + id: SessionID.make("ses_remote"), + agent: "session-agent", + model: { providerID: "session-provider", id: "session-model" }, + } as SessionInfo + const remote = RemoteCommand.create({ + list: async () => [], + command: async () => { + throw new Error("unexpected registered command") + }, + session: { + get: async () => { + steps.push("get") + return session + }, + messages: async () => { + steps.push("messages") + return [] + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { + cleanup: async () => { + steps.push("cleanup") + }, + }, + compaction: { + create: async () => { + steps.push("create") + }, + }, + prompt: { + loop: async () => { + steps.push("loop") + }, + }, + }) + + // The preflight catalog contains only the synthesized built-in "compact" (no source). + const catalog = RemoteCommand.build([]) + expect(catalog.commands.find((item) => item.name === "compact")).toEqual({ + name: "compact", + description: "compact the current session context", + hints: [], + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + catalog, + }) + + expect(steps).toEqual(["get", "cleanup", "messages", "create", "loop"]) + }) + + test("executes compact through cleanup, compaction, and prompt loop", async () => { + const steps: unknown[] = [] + const session = { + id: SessionID.make("ses_remote"), + agent: "session-agent", + model: { providerID: "session-provider", id: "session-model" }, + } as SessionInfo + const remote = RemoteCommand.create({ + list: async () => [], + command: async () => { + throw new Error("unexpected registered command") + }, + session: { + get: async () => { + steps.push("get") + return session + }, + messages: async () => { + steps.push("messages") + return [] + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { + cleanup: async (info) => { + steps.push(["cleanup", info.id]) + }, + }, + compaction: { + create: async (input) => { + steps.push(["create", input]) + }, + }, + prompt: { + loop: async (sessionID) => { + steps.push(["loop", sessionID]) + }, + }, + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + model: { providerID: "request-provider", modelID: "request-model" }, + catalog: { protocolVersion: 1, commands: [{ name: "compact", hints: [] }] }, + }) + + expect(steps).toEqual([ + "get", + ["cleanup", SessionID.make("ses_remote")], + "messages", + [ + "create", + { + sessionID: SessionID.make("ses_remote"), + agent: "session-agent", + model: { providerID: "request-provider", modelID: "request-model" }, + auto: false, + }, + ], + ["loop", SessionID.make("ses_remote")], + ]) + }) + + test("compact fallback uses the latest retained user message when no request or session overrides exist", async () => { + const steps: unknown[] = [] + const session = { + id: SessionID.make("ses_remote"), + // No session.agent and no session.model + } as unknown as SessionInfo + // Typed retained-message fixture: every field the production + // session.messages() / compaction.create() path reads is present and + // matches MessageV2.WithParts. The Assistant entry exists only to + // exercise the findLast(user) path; the latest User entry is the one + // that must supply agent + model. + const sid = SessionID.make("ses_remote") + const retained: MessageV2.WithParts[] = [ + { + info: { + id: MessageID.make("msg_old"), + role: "assistant", + sessionID: sid, + time: { created: 0 }, + parentID: MessageID.make("msg_root"), + providerID: "user-provider" as never, + modelID: "user-model" as never, + mode: "primary", + agent: "user-agent", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [], + }, + { + info: { + id: MessageID.make("msg_user"), + role: "user", + sessionID: sid, + time: { created: 1 }, + agent: "user-agent", + model: { providerID: "user-provider" as never, modelID: "user-model" as never }, + }, + parts: [], + }, + ] + const remote = RemoteCommand.create({ + list: async () => [], + command: async () => { + throw new Error("unexpected registered command") + }, + session: { + get: async () => { + steps.push("get") + return session + }, + messages: async () => { + steps.push("messages") + return retained + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { cleanup: async () => {} }, + compaction: { + create: async (input) => { + steps.push(["create", input]) + }, + }, + prompt: { loop: async () => {} }, + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + catalog: { protocolVersion: 1, commands: [{ name: "compact", hints: [] }] }, + }) + + expect(steps).toEqual([ + "get", + "messages", + [ + "create", + { + sessionID: SessionID.make("ses_remote"), + agent: "user-agent", + model: { providerID: "user-provider", modelID: "user-model" }, + auto: false, + }, + ], + ]) + }) + + test("execute reuses the supplied catalog and never calls services.list() a second time", async () => { + let listCalls = 0 + const list = async () => { + listCalls++ + return [] + } + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list, + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + + const catalog: RemoteCommand.Response = { + protocolVersion: 1, + commands: [{ name: "review", hints: [] }], + } + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "review", + arguments: "", + catalog, + }) + + expect(listCalls).toBe(0) + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_remote"), + command: "review", + arguments: "", + }, + ]) + }) + + // execute() is the last line of defense: even if a caller bypasses the + // dispatcher's preflight, a command absent from the supplied bounded + // catalog must never reach services.command(). + test("execute rejects commands absent from the supplied catalog before invoking services.command()", async () => { + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + + const catalog: RemoteCommand.Response = { + protocolVersion: 1, + commands: [{ name: "review", hints: [] }], + } + + await expect( + remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "missing", + arguments: "", + catalog, + }), + ).rejects.toThrow("unknown slash command: missing") + expect(calls).toEqual([]) + }) + + // Even when the catalog advertises "compact" (the synthesized built-in), + // a name not in the catalog must still be rejected. + test("execute rejects arbitrary names even when the catalog advertises built-in compact", async () => { + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + + // Catalog seeded with just the built-in compact (no registered "compact" + // with source=="command"|"mcp"), so the shadow check would fall back to + // the built-in path for "compact". The interesting case is a different + // unknown name — it must be rejected regardless. + const catalog = RemoteCommand.build([]) + + await expect( + remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "rm", + arguments: "", + catalog, + }), + ).rejects.toThrow("unknown slash command: rm") + expect(calls).toEqual([]) + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/remote-exit.test.ts b/packages/opencode/test/kilocode/sessions/remote-exit.test.ts new file mode 100644 index 0000000000..16b1843e9b --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/remote-exit.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { RemoteExit } from "../../../src/kilo-sessions/remote-exit" + +describe("RemoteExit", () => { + const cleanups: Array<() => void> = [] + + afterEach(() => { + while (cleanups.length) cleanups.pop()?.() + }) + + test("active unregister clears its callback", () => { + const callback = async () => {} + const unregister = RemoteExit.register(callback) + cleanups.push(unregister) + + expect(RemoteExit.get()).toBe(callback) + unregister() + expect(RemoteExit.get()).toBeUndefined() + }) + + test("stale unregister preserves a different callback replacement", () => { + const first = RemoteExit.register(async () => {}) + const replacement = async () => {} + const second = RemoteExit.register(replacement) + cleanups.push(first, second) + + first() + expect(RemoteExit.get()).toBe(replacement) + }) + + test("stale unregister preserves a same-callback replacement", () => { + const callback = async () => {} + const first = RemoteExit.register(callback) + const second = RemoteExit.register(callback) + cleanups.push(first, second) + + first() + expect(RemoteExit.get()).toBe(callback) + second() + expect(RemoteExit.get()).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index ea24ef007b..cbd5f6a49d 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" import { afterEach, mock, spyOn } from "bun:test" import { Effect } from "effect" +import { ProjectV2 } from "@opencode-ai/core/project" +import { RemoteCommand } from "../../../src/kilo-sessions/remote-command" import { RemoteModelCatalog } from "../../../src/kilo-sessions/remote-model-catalog" import { RemoteSender } from "../../../src/kilo-sessions/remote-sender" import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws" @@ -75,6 +77,18 @@ function prompts(calls: SessionPrompt.PromptInput[]) { } } +function info(id: SessionID, directory = "/workspace/project-a") { + return { + id, + slug: id, + projectID: ProjectV2.ID.make("project_test"), + directory, + title: "Test session", + version: "test", + time: { created: 0, updated: 0 }, + } satisfies Session.Info +} + function catalogModel(providerID: string, modelID: string, name: string, reasoning = false) { return { id: ModelV2.ID.make(modelID), @@ -428,7 +442,10 @@ describe("RemoteSender", () => { }, }, }) as any, - default: async () => ({ providerID: ProviderV2.ID.make("custom"), modelID: ModelV2.ID.make("deployment/model") }), + default: async () => ({ + providerID: ProviderV2.ID.make("custom"), + modelID: ModelV2.ID.make("deployment/model"), + }), }, }) @@ -793,7 +810,10 @@ describe("RemoteSender", () => { { sessionID: SessionID.make("ses_x"), parts: [{ type: "text", text: "hello" }], - model: { providerID: ProviderV2.ID.make("kilo"), modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-20250514") }, + model: { + providerID: ProviderV2.ID.make("kilo"), + modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-20250514"), + }, ephemeralTools: { interactive_terminal: false }, }, ]) @@ -1596,9 +1616,7 @@ describe("RemoteSender", () => { provide: async (input: any) => input.fn(), session: { get: async (sessionID) => - sessionID === child.id - ? child - : ({ id: sessionID, directory: "/workspace/root" } as Session.Info), + sessionID === child.id ? child : ({ id: sessionID, directory: "/workspace/root" } as Session.Info), children: async (sessionID) => (sessionID === SessionID.make("ses_root") ? [child] : []), }, question: questions(), @@ -1732,3 +1750,1180 @@ describe("RemoteSender", () => { expect(sent).toHaveLength(0) }) }) + +// kilocode_change start - remote slash command discovery and execution +describe("RemoteSender slash commands", () => { + // Wraps a Connection to expose a promise that resolves when a response with the + // given id is sent. Keeps new tests deterministic without setTimeout polling. + // Wrappers chain so multiple pending responses can be awaited on the same + // connection; the original fakeConn.send still records each emission once. + function expectResponse(conn: RemoteWS.Connection, _sent: any[], id: string) { + const resolvers = Promise.withResolvers() + const previous = conn.send.bind(conn) + const spy = (message: RemoteProtocol.Outbound) => { + if (message?.type === "response" && message.id === id) resolvers.resolve(message) + previous(message) + } + conn.send = spy + return { + promise: resolvers.promise, + restore: () => { + conn.send = previous + }, + } + } + + test("list_commands validates v1 and returns the bounded catalog from the target session directory", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const state = { directory: "" } + const first = expectResponse(conn, sent, "req_commands_first") + const second = expectResponse(conn, sent, "req_commands_second") + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + state.directory = input.directory + const result = await input.fn() + state.directory = "" + return result + }, + commands: { + list: async () => ({ + protocolVersion: 1 as const, + commands: [{ name: state.directory.endsWith("first") ? "first" : "second", hints: [] }], + }), + execute: async () => {}, + }, + catalog: { + get: async (sessionID) => + ({ + id: sessionID, + directory: sessionID === SessionID.make("ses_first") ? "/workspace/first" : "/workspace/second", + }) as any, + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + sender.handle({ + type: "command", + id: "req_commands_first", + command: "list_commands", + sessionId: "ses_first", + data: { protocolVersion: 1 }, + }) + await first.promise + sender.handle({ + type: "command", + id: "req_commands_second", + command: "list_commands", + sessionId: "ses_second", + data: { protocolVersion: 1 }, + }) + await second.promise + + first.restore() + second.restore() + + expect(dirs).toEqual(["/workspace/first", "/workspace/second"]) + expect(sent).toEqual([ + { + type: "response", + id: "req_commands_first", + result: { protocolVersion: 1, commands: [{ name: "first", hints: [] }] }, + }, + { + type: "response", + id: "req_commands_second", + result: { protocolVersion: 1, commands: [{ name: "second", hints: [] }] }, + }, + ]) + }) + + test("list_commands rejects unsupported protocol versions and missing session IDs before ACK", () => { + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: fakeBus().subscribe, + commands: { + list: async () => ({ protocolVersion: 1 as const, commands: [] }), + execute: async () => {}, + }, + }) + + sender.handle({ + type: "command", + id: "req_commands_v2", + command: "list_commands", + sessionId: "ses_x", + data: { protocolVersion: 2 }, + }) + sender.handle({ + type: "command", + id: "req_commands_no_session", + command: "list_commands", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_commands_invalid_session", + command: "list_commands", + sessionId: "not-a-session-id", + data: { protocolVersion: 1 }, + }) + + expect(sent).toEqual([ + { type: "response", id: "req_commands_v2", error: "invalid list_commands request" }, + { type: "response", id: "req_commands_no_session", error: "invalid list_commands request" }, + { type: "response", id: "req_commands_invalid_session", error: "invalid list_commands request" }, + ]) + }) + + test("send_command validates protocol, session, and catalog membership before ACK", async () => { + const { conn, sent } = fakeConn() + const calls: unknown[] = [] + const started = Promise.withResolvers() + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + commands: { + list: async () => ({ protocolVersion: 1 as const, commands: [{ name: "review", hints: [] }] }), + execute: async (input) => { + calls.push(input) + started.resolve() + await new Promise(() => {}) + }, + }, + catalog: { + get: async () => ({ id: SessionID.make("ses_commands"), directory: "/workspace/project-a" }) as any, + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + // Pre-ACK: invalid protocol + sender.handle({ + type: "command", + id: "req_bad_version", + command: "send_command", + sessionId: "ses_commands", + data: { protocolVersion: 2, command: "review", arguments: "main" }, + }) + // Pre-ACK: missing session id + sender.handle({ + type: "command", + id: "req_no_session", + command: "send_command", + data: { protocolVersion: 1, command: "review", arguments: "main" }, + }) + + // Wait for the async catalog preflight that rejects the unknown command and ACKs the valid one. + const unknownResponse = expectResponse(conn, sent, "req_unknown") + const ackResponse = expectResponse(conn, sent, "req_send_command") + + // Pre-ACK: command not in catalog + sender.handle({ + type: "command", + id: "req_unknown", + command: "send_command", + sessionId: "ses_commands", + data: { protocolVersion: 1, command: "missing", arguments: "main" }, + }) + // Valid path: ACK first + sender.handle({ + type: "command", + id: "req_send_command", + command: "send_command", + sessionId: "ses_commands", + data: { + protocolVersion: 1, + command: "review", + arguments: " main ", + messageID: "msg_remote", + }, + }) + + expect(sent.slice(0, 2)).toEqual([ + { type: "response", id: "req_bad_version", error: "invalid send_command request" }, + { type: "response", id: "req_no_session", error: "invalid send_command request" }, + ]) + + await unknownResponse.promise + unknownResponse.restore() + await ackResponse.promise + ackResponse.restore() + + expect(sent.find((m: any) => m.id === "req_unknown")).toEqual({ + type: "response", + id: "req_unknown", + error: "unknown slash command", + }) + expect(sent.find((m: any) => m.id === "req_send_command")).toEqual({ + type: "response", + id: "req_send_command", + result: {}, + }) + + await started.promise + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_commands"), + protocolVersion: 1, + command: "review", + arguments: " main ", + messageID: "msg_remote", + catalog: { protocolVersion: 1, commands: [{ name: "review", hints: [] }] }, + }, + ]) + }) + + test("send_command returns a sanitized error without ACK when the session is missing", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + commands: { + list: async () => ({ protocolVersion: 1 as const, commands: [{ name: "review", hints: [] }] }), + execute: async () => { + throw new Error("must not execute") + }, + }, + catalog: { + get: async () => { + throw new Error("private lookup detail with secret") + }, + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + const response = expectResponse(conn, sent, "req_missing_command") + sender.handle({ + type: "command", + id: "req_missing_command", + command: "send_command", + sessionId: "ses_missing", + data: { protocolVersion: 1, command: "review", arguments: "main" }, + }) + + await response.promise + response.restore() + + expect(dirs).toEqual([]) + expect(sent).toEqual([{ type: "response", id: "req_missing_command", error: "failed to send command" }]) + expect(JSON.stringify(sent)).not.toContain("private lookup detail with secret") + }) + + test("send_command logs only the error class after ACK and does not leak arguments or tokens", async () => { + class CredentialLeakError extends Error { + override name = "CredentialLeakError" + } + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { + ...nolog, + error: (...args: unknown[]) => logEntries.push(args), + }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + commands: { + list: async () => ({ protocolVersion: 1 as const, commands: [{ name: "review", hints: [] }] }), + execute: async () => { + throw new CredentialLeakError("credential=must-not-leak") + }, + }, + catalog: { + get: async () => ({ id: SessionID.make("ses_commands"), directory: "/workspace/project-a" }) as any, + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + const ack = expectResponse(conn, sent, "req_failed_command") + sender.handle({ + type: "command", + id: "req_failed_command", + command: "send_command", + sessionId: "ses_commands", + data: { + protocolVersion: 1, + command: "review", + arguments: "secret=token-must-not-leak", + model: { providerID: "custom/edge", modelID: "deployment/model" }, + }, + }) + + await ack.promise + // The post-ACK throw is logged via the same adapter; give the microtask a + // chance to drain so the log entry is captured before assertions. + await Promise.resolve() + ack.restore() + + expect(sent).toEqual([{ type: "response", id: "req_failed_command", result: {} }]) + expect(logEntries).toHaveLength(1) + expect(logEntries[0]?.[0]).toBe("send command failed after ACK") + expect(logEntries[0]?.[1]).toEqual({ + id: "req_failed_command", + operation: "send_command", + error: "CredentialLeakError", + }) + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("token-must-not-leak") + expect(flattened).not.toContain("credential=must-not-leak") + expect(flattened).not.toContain("secret=") + }) + + test("send_command rejects process exit before ACK without invoking command or graceful exit", async () => { + const { conn, sent } = fakeConn() + const calls: unknown[] = [] + let callbacks = 0 + const commands = RemoteCommand.create({ + exitAvailable: () => true, + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected command session lookup") + }, + messages: async () => { + throw new Error("unexpected command message lookup") + }, + }, + agent: { default: async () => "unexpected-agent" }, + provider: { default: async () => ({ providerID: "unexpected", modelID: "unexpected" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + commands, + remoteExit: { + get: () => { + callbacks += 1 + return async () => {} + }, + }, + catalog: { + get: async (id) => info(id), + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + const response = expectResponse(conn, sent, "req_send_exit") + sender.handle({ + type: "command", + id: "req_send_exit", + command: "send_command", + sessionId: "ses_current", + data: { protocolVersion: 1, command: "exit", arguments: "" }, + }) + await response.promise + response.restore() + await Promise.resolve() + + expect(sent).toEqual([{ type: "response", id: "req_send_exit", error: "unknown slash command" }]) + expect(calls).toEqual([]) + expect(callbacks).toBe(0) + }) + + test("exit_cli rejects invalid, missing, unresolved, and unavailable sessions before ACK", async () => { + const { conn, sent } = fakeConn() + const lookups: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async (id) => { + lookups.push(id) + if (id === SessionID.make("ses_missing")) throw new Error("private missing-session detail") + return info(id) + }, + children: async () => [], + }, + remoteExit: { + get: () => undefined, + }, + }) + + sender.handle({ + type: "command", + id: "req_exit_no_session", + command: "exit_cli", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_exit_invalid_session", + command: "exit_cli", + sessionId: "not-a-session-id", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_exit_bad_protocol", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 2 }, + }) + sender.handle({ + type: "command", + id: "req_exit_extra", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 1, extra: true }, + }) + + const missing = expectResponse(conn, sent, "req_exit_missing") + sender.handle({ + type: "command", + id: "req_exit_missing", + command: "exit_cli", + sessionId: "ses_missing", + data: { protocolVersion: 1 }, + }) + await missing.promise + missing.restore() + + const unavailable = expectResponse(conn, sent, "req_exit_unavailable") + sender.handle({ + type: "command", + id: "req_exit_unavailable", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await unavailable.promise + unavailable.restore() + + expect(sent).toEqual([ + { type: "response", id: "req_exit_no_session", error: "invalid exit_cli command" }, + { type: "response", id: "req_exit_invalid_session", error: "invalid exit_cli command" }, + { type: "response", id: "req_exit_bad_protocol", error: "invalid exit_cli command" }, + { type: "response", id: "req_exit_extra", error: "invalid exit_cli command" }, + { type: "response", id: "req_exit_missing", error: "failed to exit CLI" }, + { type: "response", id: "req_exit_unavailable", error: "graceful exit unavailable" }, + ]) + expect(lookups).toEqual(["ses_missing", "ses_current"]) + }) + + test("exit_cli ACKs before invoking the worker callback in a microtask", async () => { + const { conn, sent } = fakeConn() + const order: string[] = [] + const tasks: VoidFunction[] = [] + const original = globalThis.queueMicrotask + globalThis.queueMicrotask = (task) => { + tasks.push(task) + } + const send = conn.send.bind(conn) + conn.send = (message) => { + order.push("response") + send(message) + } + const invoked = Promise.withResolvers() + const remoteExit = { + get: () => async () => { + order.push("callback") + invoked.resolve() + }, + } + try { + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async (id) => info(id), + children: async () => [], + }, + remoteExit, + }) + + const ack = expectResponse(conn, sent, "req_exit") + sender.handle({ + type: "command", + id: "req_exit", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await ack.promise + ack.restore() + + expect(sent).toEqual([{ type: "response", id: "req_exit", result: {} }]) + expect(order).toEqual(["response"]) + expect(tasks).toHaveLength(1) + + tasks[0]?.() + await invoked.promise + expect(order).toEqual(["response", "callback"]) + } finally { + globalThis.queueMicrotask = original + } + }) + + test("concurrent exit_cli requests ACK while idempotent local Exit cleans up once", async () => { + const { conn, sent } = fakeConn() + const completed = Promise.withResolvers() + const calls: string[] = [] + let task: Promise | undefined + const exit = () => + (task ??= Promise.resolve().then(() => { + calls.push("cleanup") + completed.resolve() + })) + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async (id) => info(id), + children: async () => [], + }, + remoteExit: { + get: () => exit, + }, + }) + + sender.handle({ + type: "command", + id: "req_exit_first", + command: "exit_cli", + sessionId: "ses_first", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_exit_second", + command: "exit_cli", + sessionId: "ses_second", + data: { protocolVersion: 1 }, + }) + await completed.promise + await Promise.resolve() + + expect(sent).toEqual([ + { type: "response", id: "req_exit_first", result: {} }, + { type: "response", id: "req_exit_second", result: {} }, + ]) + expect(calls).toEqual(["cleanup"]) + }) + + test("exit_cli logs only the error class when graceful exit fails after ACK", async () => { + class CredentialLeakError extends Error { + override name = "CredentialLeakError" + } + const { conn, sent } = fakeConn() + const logs: unknown[][] = [] + const logged = Promise.withResolvers() + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { + ...nolog, + error: (...args: unknown[]) => { + logs.push(args) + logged.resolve() + }, + }, + subscribe: fakeBus().subscribe, + session: { + get: async (id) => info(id), + children: async () => [], + }, + remoteExit: { + get: () => async () => { + throw new CredentialLeakError("token=must-not-leak") + }, + }, + }) + + sender.handle({ + type: "command", + id: "req_exit_failed", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await logged.promise + + expect(sent).toEqual([{ type: "response", id: "req_exit_failed", result: {} }]) + expect(logs).toEqual([ + ["exit CLI failed after ACK", { id: "req_exit_failed", operation: "exit_cli", error: "CredentialLeakError" }], + ]) + expect(JSON.stringify(logs)).not.toContain("must-not-leak") + expect(JSON.stringify(logs)).not.toContain("token=") + }) + + test("create_session creates a root session in the current directory and responds in order", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const createCalls: { input: unknown; calls: number } = { input: undefined, calls: 0 } + const attachCalls: string[] = [] + const order: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async (input) => { + createCalls.calls += 1 + createCalls.input = input + order.push("create") + return { id: SessionID.make("ses_new"), directory: "/workspace/project-a", parentID: undefined } as any + }, + }, + attachSession: async (id) => { + attachCalls.push(id) + order.push("attach") + // The production attachSession is responsible for the heartbeat; the + // mock follows the same contract so the ordering assertion below + // exercises the real shape of: create -> attach -> heartbeat -> response. + await (conn as any).heartbeat() + }, + }) + ;(conn as any).heartbeat = async () => { + order.push("heartbeat") + } + + const response = expectResponse(conn, sent, "req_create") + sender.handle({ + type: "command", + id: "req_create", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(dirs).toEqual(["/workspace/project-a"]) + expect(createCalls.calls).toBe(1) + expect(createCalls.input).toEqual({}) + expect(attachCalls).toEqual(["ses_new"]) + expect(order).toEqual(["create", "attach", "heartbeat"]) + expect(sent).toEqual([{ type: "response", id: "req_create", result: { protocolVersion: 1, sessionID: "ses_new" } }]) + }) + + test("create_session rejects unsupported protocol versions and missing or invalid session IDs", async () => { + const { conn, sent } = fakeConn() + const createCalls: unknown[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async () => { + throw new Error("must not look up session for invalid request") + }, + children: async () => [], + create: async (input) => { + createCalls.push(input) + return { id: SessionID.make("ses_unused") } as any + }, + }, + attachSession: async () => { + throw new Error("must not attach for invalid request") + }, + }) + ;(conn as any).heartbeat = async () => { + throw new Error("must not heartbeat for invalid request") + } + + sender.handle({ + type: "command", + id: "req_v2", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 2 }, + }) + sender.handle({ + type: "command", + id: "req_no_session", + command: "create_session", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_bad_session", + command: "create_session", + sessionId: "not-a-session-id", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_extra_field", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1, extra: true }, + }) + + expect(sent).toEqual([ + { type: "response", id: "req_v2", error: "invalid create_session command" }, + { type: "response", id: "req_no_session", error: "invalid create_session command" }, + { type: "response", id: "req_bad_session", error: "invalid create_session command" }, + { type: "response", id: "req_extra_field", error: "invalid create_session command" }, + ]) + expect(createCalls).toHaveLength(0) + }) + + test("create_session returns a sanitized error and never reports success when creation throws", async () => { + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const attachCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => { + throw new Error("private failure detail: token=must-not-leak") + }, + }, + attachSession: async (id) => { + attachCalls.push(id) + }, + }) + ;(conn as any).heartbeat = async () => {} + + const response = expectResponse(conn, sent, "req_create_failed") + sender.handle({ + type: "command", + id: "req_create_failed", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(sent).toEqual([{ type: "response", id: "req_create_failed", error: "failed to create session" }]) + expect(attachCalls).toEqual([]) + expect(logEntries).toHaveLength(1) + expect(logEntries[0]?.[0]).toBe("create session failed") + expect(logEntries[0]?.[1]).toEqual({ id: "req_create_failed", error: "Error" }) + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("must-not-leak") + expect(flattened).not.toContain("token=") + }) + + test("create_session returns a sanitized error and never reports success when heartbeat throws", async () => { + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const attachCalls: string[] = [] + const removeCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any, + remove: async (id) => { + removeCalls.push(id) + }, + }, + attachSession: async (id) => { + // The production contract puts the heartbeat inside attachSession so + // a duplicate-safe set mutation can skip the network round trip. + attachCalls.push(id) + await (conn as any).heartbeat() + }, + }) + ;(conn as any).heartbeat = async () => { + throw new Error("private relay detail: credential=must-not-leak") + } + + const response = expectResponse(conn, sent, "req_heartbeat_failed") + sender.handle({ + type: "command", + id: "req_heartbeat_failed", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(sent).toEqual([{ type: "response", id: "req_heartbeat_failed", error: "failed to create session" }]) + expect(attachCalls).toEqual(["ses_new"]) + // The orphan rollback must have been attempted for the created session. + expect(removeCalls).toEqual(["ses_new"]) + expect(logEntries).toHaveLength(1) + expect(logEntries[0]?.[0]).toBe("create session failed") + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("must-not-leak") + expect(flattened).not.toContain("credential=") + }) + + test("create_session rolls back the created session when attachSession fails", async () => { + const { conn, sent } = fakeConn() + const removeCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any, + remove: async (id) => { + removeCalls.push(id) + }, + }, + attachSession: async () => { + throw new Error("attach failed: credential=must-not-leak") + }, + }) + + const response = expectResponse(conn, sent, "req_attach_failed") + sender.handle({ + type: "command", + id: "req_attach_failed", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + // The created session was rolled back and the caller sees the generic + // sanitized failure — never a partial success. + expect(removeCalls).toEqual(["ses_new"]) + expect(sent).toEqual([{ type: "response", id: "req_attach_failed", error: "failed to create session" }]) + }) + + test("create_session preserves the original attach error when the rollback itself fails", async () => { + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any, + remove: async () => { + throw new Error("cleanup secondary failure") + }, + }, + attachSession: async () => { + throw new Error("primary attach failure: credential=must-not-leak") + }, + }) + + const response = expectResponse(conn, sent, "req_attach_then_cleanup_fail") + sender.handle({ + type: "command", + id: "req_attach_then_cleanup_fail", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + // The caller sees the sanitized primary failure, not the cleanup error. + expect(sent).toEqual([{ type: "response", id: "req_attach_then_cleanup_fail", error: "failed to create session" }]) + // The cleanup failure is logged for observability but does not leak the + // primary attach error message to the response or to the cleanup log. + const cleanupLog = logEntries.find((entry) => entry[0] === "create session cleanup failed") + expect(cleanupLog).toBeDefined() + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("must-not-leak") + expect(flattened).not.toContain("credential=") + }) + + test("create_session does not remove the created session when attach succeeds", async () => { + const { conn, sent } = fakeConn() + const removeCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any, + remove: async (id) => { + removeCalls.push(id) + }, + }, + attachSession: async () => { + await (conn as any).heartbeat() + }, + }) + ;(conn as any).heartbeat = async () => {} + + const response = expectResponse(conn, sent, "req_create_success") + sender.handle({ + type: "command", + id: "req_create_success", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(removeCalls).toEqual([]) + expect(sent).toEqual([ + { type: "response", id: "req_create_success", result: { protocolVersion: 1, sessionID: "ses_new" } }, + ]) + }) + + test("create_session runs in the current session's directory", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + session: { + get: async (sessionID) => { + if (sessionID === SessionID.make("ses_alpha")) return { id: sessionID, directory: "/workspace/alpha" } as any + if (sessionID === SessionID.make("ses_beta")) return { id: sessionID, directory: "/workspace/beta" } as any + throw new Error("unknown session") + }, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_new") }) as any, + }, + attachSession: async () => {}, + }) + ;(conn as any).heartbeat = async () => {} + + const first = expectResponse(conn, sent, "req_create_alpha") + sender.handle({ + type: "command", + id: "req_create_alpha", + command: "create_session", + sessionId: "ses_alpha", + data: { protocolVersion: 1 }, + }) + await first.promise + first.restore() + + const second = expectResponse(conn, sent, "req_create_beta") + sender.handle({ + type: "command", + id: "req_create_beta", + command: "create_session", + sessionId: "ses_beta", + data: { protocolVersion: 1 }, + }) + await second.promise + second.restore() + + expect(dirs).toEqual(["/workspace/alpha", "/workspace/beta"]) + }) + + test("create_session dispatches attach and heartbeat for each call", async () => { + const { conn, sent } = fakeConn() + const attachCalls: string[] = [] + let heartbeatCalls = 0 + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_same") }) as any, + }, + attachSession: async (id) => { + attachCalls.push(id) + await (conn as any).heartbeat() + }, + }) + ;(conn as any).heartbeat = async () => { + heartbeatCalls += 1 + } + + const first = expectResponse(conn, sent, "req_create_same_first") + sender.handle({ + type: "command", + id: "req_create_same_first", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await first.promise + first.restore() + + const second = expectResponse(conn, sent, "req_create_same_second") + sender.handle({ + type: "command", + id: "req_create_same_second", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await second.promise + second.restore() + + // Each request is a separate create_session call, so the production + // attachSession is invoked twice. The de-duplication of the attached set + // itself is the responsibility of the attachSession hook (see the + // duplicate-safe test below). + expect(attachCalls).toEqual(["ses_same", "ses_same"]) + expect(heartbeatCalls).toBe(2) + }) + + test("create_session does not call heartbeat when the new session is already attached", async () => { + const { conn, sent } = fakeConn() + let heartbeatCalls = 0 + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_existing") }) as any, + }, + attachSession: async () => { + // Simulate a duplicate-safe attach: nothing to do, no heartbeat needed. + }, + }) + ;(conn as any).heartbeat = async () => { + heartbeatCalls += 1 + } + + const response = expectResponse(conn, sent, "req_create_existing") + sender.handle({ + type: "command", + id: "req_create_existing", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + // The mock attachSession is a no-op (the duplicate-safe contract), so the + // sender must NOT call conn.heartbeat() on its own. + expect(heartbeatCalls).toBe(0) + expect(sent).toEqual([ + { type: "response", id: "req_create_existing", result: { protocolVersion: 1, sessionID: "ses_existing" } }, + ]) + }) + + test("create_session returns a sanitized error and never reports success when current session.get throws", async () => { + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const createCalls: unknown[] = [] + const attachCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) }, + subscribe: fakeBus().subscribe, + session: { + get: async () => { + throw new Error("private lookup failure: token=must-not-leak and path=/workspace/private") + }, + children: async () => [], + create: async (input) => { + createCalls.push(input) + return { id: SessionID.make("ses_unused") } as any + }, + }, + attachSession: async (id) => { + attachCalls.push(id) + }, + }) + ;(conn as any).heartbeat = async () => {} + + const response = expectResponse(conn, sent, "req_create_get_failed") + sender.handle({ + type: "command", + id: "req_create_get_failed", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(sent).toEqual([{ type: "response", id: "req_create_get_failed", error: "failed to create session" }]) + expect(createCalls).toEqual([]) + expect(attachCalls).toEqual([]) + expect(logEntries).toHaveLength(1) + expect(logEntries[0]?.[0]).toBe("create session failed") + // Only the error class is logged — no message, no path, no token. + expect(logEntries[0]?.[1]).toEqual({ id: "req_create_get_failed", error: "Error" }) + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("must-not-leak") + expect(flattened).not.toContain("token=") + expect(flattened).not.toContain("/workspace/private") + // The request payload itself must never reach the log. + expect(flattened).not.toContain("ses_current") + }) +}) +// kilocode_change end diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json index 4f26751f55..aad8604576 100644 --- a/packages/plugin-atomic-chat/package.json +++ b/packages/plugin-atomic-chat/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.9", + "version": "7.4.11", "description": "Kilo Code plugin for Atomic Chat: auto-detection and dynamic model discovery (OpenAI-compatible local API)", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 969093fda7..e843ede97b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index bce0a9d42f..ad3055f715 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.4.9", + "version": "7.4.11", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 25c823ab63..b2a455124b 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.4.9", + "version": "7.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 1d5cecc8e1..748a58b11f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -276,7 +276,14 @@ export type AgentManagerPromptRequest = { prompt: string } -export type AgentManagerRequest = AgentManagerOverviewRequest | AgentManagerPromptRequest +export type AgentManagerStopRequest = { + id: AgentManagerRequestId + sessionID: string + operation: "stop" + targetSessionID: string +} + +export type AgentManagerRequest = AgentManagerOverviewRequest | AgentManagerPromptRequest | AgentManagerStopRequest export type NotebookRequestId = string @@ -3309,7 +3316,13 @@ export type AgentManagerPromptResult = { delivered: true } -export type AgentManagerResult = AgentManagerOverviewResult | AgentManagerPromptResult +export type AgentManagerStopResult = { + operation: "stop" + sessionID: string + stopped: true +} + +export type AgentManagerResult = AgentManagerOverviewResult | AgentManagerPromptResult | AgentManagerStopResult export type AgentManagerFailure = { code: diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c75ebfc776..9548c9143f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -24316,6 +24316,28 @@ "required": ["id", "sessionID", "operation", "targetSessionID", "prompt"], "additionalProperties": false }, + "AgentManagerStopRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/AgentManagerRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "operation": { + "type": "string", + "enum": ["stop"] + }, + "targetSessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["id", "sessionID", "operation", "targetSessionID"], + "additionalProperties": false + }, "AgentManagerRequest": { "anyOf": [ { @@ -24323,6 +24345,9 @@ }, { "$ref": "#/components/schemas/AgentManagerPromptRequest" + }, + { + "$ref": "#/components/schemas/AgentManagerStopRequest" } ] }, @@ -33428,6 +33453,25 @@ "required": ["operation", "sessionID", "delivered"], "additionalProperties": false }, + "AgentManagerStopResult": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["stop"] + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "stopped": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["operation", "sessionID", "stopped"], + "additionalProperties": false + }, "AgentManagerResult": { "anyOf": [ { @@ -33435,6 +33479,9 @@ }, { "$ref": "#/components/schemas/AgentManagerPromptResult" + }, + { + "$ref": "#/components/schemas/AgentManagerStopResult" } ] }, diff --git a/packages/server/package.json b/packages/server/package.json index fe4125cc6c..96a0bfbe4b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "7.4.9", + "version": "7.4.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index ccf1ee0cb7..fff8c7905f 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.4.9", + "version": "7.4.11", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 1a4b5e4bb8..3c706eac14 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -6,6 +6,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { ClipboardProvider, useClipboard } from "./context/clipboard" import { ExitProvider, useExit } from "./context/exit" +import type { Exit } from "./context/exit" // kilocode_change import { EpilogueProvider } from "./context/epilogue" import * as Selection from "./util/selection" import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core" @@ -146,6 +147,7 @@ export type TuiInput = { headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost + onExit?: (exit: Exit) => void // kilocode_change - expose the extracted TUI exit to the CLI worker bridge } function errorMessage(error: unknown) { @@ -239,15 +241,18 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" if (renderer.isDestroyed) return + // kilocode_change start - expose the renderer-owned exit without moving remote RPC into the shared TUI + const close: Exit = (reason) => { + if (renderer.isDestroyed) return + exit.reason = reason + destroyRenderer(renderer) + } + input.onExit?.(close) + // kilocode_change end + await render(() => { return ( - { - if (renderer.isDestroyed) return - exit.reason = reason - destroyRenderer(renderer) - }} - > + (exit.epilogue = value)}> }> ol { margin-top: 0.25rem; margin-bottom: 0.25rem; - padding-left: 1rem; /* Minimal indent for nesting only */ + padding-inline-start: 1rem; /* Minimal indent for nesting only */ /* kilocode_change */ } li > ol { - padding-left: 1.75rem; + padding-inline-start: 1.75rem; /* kilocode_change */ } /* Blockquotes */ blockquote { - border-left: 2px solid var(--border-weak-base); + border-inline-start: 2px solid var(--border-weak-base); /* kilocode_change */ margin: 1.5rem 0; - padding-left: 0.5rem; + padding-inline-start: 0.5rem; /* kilocode_change */ color: var(--text-weak); font-style: normal; } @@ -255,7 +255,7 @@ /* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */ border-bottom: 1px solid var(--border-weaker-base); padding: 12px; - text-align: left; + text-align: start; /* kilocode_change */ vertical-align: top; } diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 822b94c02f..a46b5fbd1e 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -544,6 +544,7 @@ export function Markdown( return (
<>
-
- {/* kilocode_change */} +
{/* kilocode_change */}
{/* kilocode_change start */} diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index ce91a22399..050b700526 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -29,6 +29,13 @@ const BLOCK = /^\$\$\n((?:\\[^]|[^\\])+?)\n\$\$(?:\n|$)/ const INLINE = /^\$\$(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\$\$/ // kilocode_change end +// kilocode_change start: isolate KaTeX from the markdown root dir=auto. +function renderKatex(text: string, options: katex.KatexOptions): string { + const html = katex.renderToString(text, options) + return `${html}` +} +// kilocode_change end + function renderMathInText(text: string): string { let result = text @@ -36,10 +43,12 @@ function renderMathInText(text: string): string { const displayMathRegex = /\$\$([\s\S]*?)\$\$/g result = result.replace(displayMathRegex, (_, math) => { try { - return katex.renderToString(math, { + // kilocode_change start + return renderKatex(math, { displayMode: true, throwOnError: false, }) + // kilocode_change end } catch { return `$$${math}$$` } @@ -153,6 +162,8 @@ function replaceWithHighlighted(block: Element, html: string, sourceHash: string temp.innerHTML = html const highlighted = temp.firstElementChild if (!highlighted) return + const dir = pre.getAttribute("dir") // kilocode_change + if (dir) highlighted.setAttribute("dir", dir) // kilocode_change // Store a hash of the source code so the morphdom guard in Markdown can detect // mid-stream content changes without keeping the full source in the DOM. highlighted.setAttribute("data-source-hash", sourceHash) @@ -264,121 +275,125 @@ export async function deferredHighlight( } // kilocode_change end +// kilocode_change start: expose the parser setup for Kilo markdown tests. +export const createMarkedParser = (props: { nativeParser?: NativeMarkdownParser }) => { + // kilocode_change start: two-pass parser — first pass skips Shiki highlighting + // to avoid blocking the main thread with Oniguruma WASM regex (issue #6221). + // Code blocks render as plain
 immediately.
+  // The Markdown component calls deferredHighlight() after DOM paint.
+  const parser = marked.use(
+    {
+      renderer: {
+        link({ href, title, text }) {
+          const titleAttr = title ? ` title="${title}"` : ""
+          return `${text}`
+        },
+        // kilocode_change start
+        codespan({ text }) {
+          const file = parseFilePath(text)
+          const escaped = text
+            .replace(/&/g, "&")
+            .replace(//g, ">")
+            .replace(/"/g, """)
+            .replace(/'/g, "'")
+          if (file) {
+            const lineAttr = file.line ? ` data-file-line="${file.line}"` : ""
+            const colAttr = file.column ? ` data-file-col="${file.column}"` : ""
+            return `${escaped}`
+          }
+          return `${escaped}`
+        },
+        code({ text, lang }) {
+          const escaped = text
+            .replace(/&/g, "&")
+            .replace(//g, ">")
+            .replace(/"/g, """)
+            .replace(/'/g, "'")
+          // Normalize aliases (e.g. "c++" → "cpp") before stripping special
+          // chars, so "c++" doesn't become "c" (wrong language highlight).
+          const normalized = lang ? (LANG_ALIASES[lang] ?? lang) : ""
+          const safe = normalized ? normalized.replace(/[^a-zA-Z0-9_-]/g, "") : ""
+          const data = safe.toLowerCase() === "mermaid" ? "mermaid" : safe
+          const attr = data ? ` class="language-${data}" data-lang="${data}"` : ' data-lang="text"'
+          return `
${escaped}
` + }, + // kilocode_change end + }, + }, + // kilocode_change start: enable only double-dollar math. + // Single $ is far more common as a currency symbol in agent responses + // (e.g. $93K, $307K) than as a LaTeX delimiter. Avoid registering the + // marked-katex-extension inline tokenizer because Marked falls through + // to later tokenizers when an override returns undefined. + { + extensions: [ + { + name: "doubleKatexBlock", + level: "block" as const, + tokenizer(src) { + const match = src.match(BLOCK) + const text = match?.[1] + if (!match || !text) return undefined + return { + type: "doubleKatexBlock", + raw: match[0], + text: text.trim(), + } + }, + renderer(token) { + return `${renderKatex(token.text, { displayMode: true, throwOnError: false })}\n` + }, + } satisfies TokenizerAndRendererExtension, + { + name: "doubleKatexInline", + level: "inline" as const, + start(src) { + const index = src.indexOf("$$") + if (index === -1) return undefined + return index + }, + tokenizer(src) { + const match = src.match(INLINE) + const text = match?.[1] + if (!match || !text) return undefined + return { + type: "doubleKatexInline", + raw: match[0], + text: text.trim(), + } + }, + renderer(token) { + return renderKatex(token.text, { displayMode: true, throwOnError: false }) + }, + } satisfies TokenizerAndRendererExtension, + ], + } satisfies MarkedExtension, + // kilocode_change end + // kilocode_change: markedShiki removed — the custom `code` renderer + // above returns plain
 and markdown.tsx
+    // calls deferredHighlight() after paint. Running Shiki inside parse
+    // blocks the main thread on session switches (issue #6221).
+  )
+  // kilocode_change end
+
+  if (props.nativeParser) {
+    const nativeParser = props.nativeParser
+    return {
+      async parse(markdown: string): Promise {
+        const html = await nativeParser(markdown)
+        const withMath = renderMathExpressions(html)
+        return highlightCodeBlocks(withMath)
+      },
+    }
+  }
+
+  return parser
+}
+
 export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
   name: "Marked",
-  init: (props: { nativeParser?: NativeMarkdownParser }) => {
-    // kilocode_change start: two-pass parser — first pass skips Shiki highlighting
-    // to avoid blocking the main thread with Oniguruma WASM regex (issue #6221).
-    // Code blocks render as plain 
 immediately.
-    // The Markdown component calls deferredHighlight() after DOM paint.
-    const parser = marked.use(
-      {
-        renderer: {
-          link({ href, title, text }) {
-            const titleAttr = title ? ` title="${title}"` : ""
-            return `${text}`
-          },
-          // kilocode_change start
-          codespan({ text }) {
-            const file = parseFilePath(text)
-            const escaped = text
-              .replace(/&/g, "&")
-              .replace(//g, ">")
-              .replace(/"/g, """)
-              .replace(/'/g, "'")
-            if (file) {
-              const lineAttr = file.line ? ` data-file-line="${file.line}"` : ""
-              const colAttr = file.column ? ` data-file-col="${file.column}"` : ""
-              return `${escaped}`
-            }
-            return `${escaped}`
-          },
-          code({ text, lang }) {
-            const escaped = text
-              .replace(/&/g, "&")
-              .replace(//g, ">")
-              .replace(/"/g, """)
-              .replace(/'/g, "'")
-            // Normalize aliases (e.g. "c++" → "cpp") before stripping special
-            // chars, so "c++" doesn't become "c" (wrong language highlight).
-            const normalized = lang ? (LANG_ALIASES[lang] ?? lang) : ""
-            const safe = normalized ? normalized.replace(/[^a-zA-Z0-9_-]/g, "") : ""
-            const data = safe.toLowerCase() === "mermaid" ? "mermaid" : safe
-            const attr = data ? ` class="language-${data}" data-lang="${data}"` : ' data-lang="text"'
-            return `
${escaped}
` - }, - // kilocode_change end - }, - }, - // kilocode_change start: enable only double-dollar math. - // Single $ is far more common as a currency symbol in agent responses - // (e.g. $93K, $307K) than as a LaTeX delimiter. Avoid registering the - // marked-katex-extension inline tokenizer because Marked falls through - // to later tokenizers when an override returns undefined. - { - extensions: [ - { - name: "doubleKatexBlock", - level: "block" as const, - tokenizer(src) { - const match = src.match(BLOCK) - const text = match?.[1] - if (!match || !text) return undefined - return { - type: "doubleKatexBlock", - raw: match[0], - text: text.trim(), - } - }, - renderer(token) { - return `${katex.renderToString(token.text, { displayMode: true, throwOnError: false })}\n` - }, - } satisfies TokenizerAndRendererExtension, - { - name: "doubleKatexInline", - level: "inline" as const, - start(src) { - const index = src.indexOf("$$") - if (index === -1) return undefined - return index - }, - tokenizer(src) { - const match = src.match(INLINE) - const text = match?.[1] - if (!match || !text) return undefined - return { - type: "doubleKatexInline", - raw: match[0], - text: text.trim(), - } - }, - renderer(token) { - return katex.renderToString(token.text, { displayMode: true, throwOnError: false }) - }, - } satisfies TokenizerAndRendererExtension, - ], - } satisfies MarkedExtension, - // kilocode_change end - // kilocode_change: markedShiki removed — the custom `code` renderer - // above returns plain
 and markdown.tsx
-      // calls deferredHighlight() after paint. Running Shiki inside parse
-      // blocks the main thread on session switches (issue #6221).
-    )
-    // kilocode_change end
-
-    if (props.nativeParser) {
-      const nativeParser = props.nativeParser
-      return {
-        async parse(markdown: string): Promise {
-          const html = await nativeParser(markdown)
-          const withMath = renderMathExpressions(html)
-          return highlightCodeBlocks(withMath)
-        },
-      }
-    }
-
-    return parser
-  },
+  init: createMarkedParser,
 })
+// kilocode_change end
diff --git a/packages/ui/src/kilocode/markdown-bidi.test.ts b/packages/ui/src/kilocode/markdown-bidi.test.ts
new file mode 100644
index 0000000000..4382f41e6c
--- /dev/null
+++ b/packages/ui/src/kilocode/markdown-bidi.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, test } from "bun:test"
+import { Window } from "happy-dom"
+import path from "node:path"
+import { createMarkedParser } from "../context/marked"
+import { fnv1a } from "../context/marked"
+import { update } from "./markdown-stream-highlight"
+
+const root = path.resolve(import.meta.dir, "../..")
+
+describe("Markdown bidirectional rendering contract", () => {
+  test("renders the markdown root with automatic direction", () => {
+    const code = String.raw`
+      import { mock } from "bun:test"
+      import { createComponent, renderToString } from "solid-js/web"
+
+      function attr(props) {
+        return Object.entries(props || {})
+          .filter(([key, value]) => key !== "children" && value != null && value !== false && typeof value !== "object")
+          .map(([key, value]) => " " + (key === "className" ? "class" : key) + "=\"" + String(value) + "\"")
+          .join("")
+      }
+
+      globalThis.React = {
+        createElement(type, props, ...children) {
+          const next = { ...(props || {}) }
+          if (children.length) next.children = children.length === 1 ? children[0] : children
+          if (typeof type === "function") return createComponent(type, next)
+          return "<" + type + attr(next) + ">" + children.join("") + ""
+        },
+      }
+
+      mock.module("./src/context/marked", () => ({
+        useMarked: () => ({ parse: async () => "" }),
+        deferredHighlight: async () => {},
+        fnv1a: (text) => text,
+      }))
+      mock.module("./src/kilocode/markdown-mermaid", () => ({
+        hasMermaid: () => false,
+        preserveMermaid: () => false,
+        renderMermaid: async () => {},
+      }))
+
+      const { Markdown } = await import("./src/components/markdown")
+      console.log(renderToString(() => createComponent(Markdown, { text: "hello" })))
+    `
+    const proc = Bun.spawnSync({
+      cmd: ["bun", "-e", code],
+      cwd: root,
+      stdout: "pipe",
+      stderr: "pipe",
+    })
+
+    expect(proc.exitCode, proc.stderr.toString()).toBe(0)
+    const html = proc.stdout.toString()
+    expect(html).toContain('data-component="markdown"')
+    expect(html).toContain('dir="auto"')
+  })
+
+  test("renders code, pre, and math with isolated direction", async () => {
+    const parser = createMarkedParser({})
+    const html = await Promise.resolve(
+      parser.parse(
+        ["متن با `inlineCode`", "", "```ts", "const value = 1", "```", "", "$$", "a = b + c", "$$"].join("\n"),
+      ),
+    )
+
+    expect(html).toContain('inlineCode')
+    expect(html).toContain('
')
+    expect(html).toContain("const value = 1")
+    expect(html.match(/ {
+    const win = new Window()
+    const scope = globalThis as typeof globalThis & {
+      document: Document
+      HTMLPreElement: typeof HTMLPreElement
+    }
+    const doc = scope.document
+    const elem = scope.HTMLPreElement
+
+    try {
+      scope.document = win.document as unknown as Document
+      scope.HTMLPreElement = win.HTMLPreElement as unknown as typeof HTMLPreElement
+
+      const pre = document.createElement("pre")
+      const code = "const value = 1"
+      pre.setAttribute("dir", "auto")
+      pre.setAttribute("data-old", "removed")
+      pre.scrollLeft = 24
+      pre.innerHTML = `${code}`
+      document.body.append(pre)
+
+      update(pre, `
${code}
`, code) + + expect(pre.getAttribute("dir")).toBe("auto") + expect(pre.className).toBe("shiki") + expect(pre.getAttribute("tabindex")).toBe("0") + expect(pre.hasAttribute("data-old")).toBe(false) + expect(pre.getAttribute("data-source-hash")).toBe(fnv1a(code)) + expect(pre.textContent).toBe(code) + expect(pre.scrollLeft).toBe(24) + } finally { + scope.document = doc + scope.HTMLPreElement = elem + } + }) +}) diff --git a/packages/ui/src/kilocode/markdown-stream-highlight.ts b/packages/ui/src/kilocode/markdown-stream-highlight.ts index 993199a093..2021fca95f 100644 --- a/packages/ui/src/kilocode/markdown-stream-highlight.ts +++ b/packages/ui/src/kilocode/markdown-stream-highlight.ts @@ -29,8 +29,9 @@ async function source(lang: string, code: string) { } } -function update(pre: HTMLPreElement, html: string, code: string) { +export function update(pre: HTMLPreElement, html: string, code: string) { if (!pre.isConnected) return + const dir = pre.getAttribute("dir") ?? "auto" const temp = document.createElement("div") temp.innerHTML = html const next = temp.firstElementChild @@ -42,6 +43,7 @@ function update(pre: HTMLPreElement, html: string, code: string) { for (const attr of next.attributes) { pre.setAttribute(attr.name, attr.value) } + pre.setAttribute("dir", dir) pre.setAttribute("data-source-hash", fnv1a(code)) pre.replaceChildren(...Array.from(next.childNodes)) pre.scrollLeft = x diff --git a/script/upstream/package.json b/script/upstream/package.json index 4b0c3bfbed..cbd3af40f5 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.4.9", + "version": "7.4.11", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo",