diff --git a/CHANGELOG.md b/CHANGELOG.md index f31a5864..6035e951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.8.0] — 2026-08-28 + +**A chat in the IDE can now be driven from claude.ai or the Claude app**, and keeps running on your own +machine the whole time. + +### Added +- **Remote Control for a chat**, from a phone button in the chat's button row — left of the guard's shield — + or from *Remote control* in the ⚙ menu. Execution and filesystem access stay local: your files, your MCP + servers and your project configuration remain the ones in use, and claude.ai or the Claude app becomes a + second window onto the same conversation. The transcript announces the session URL when the reply carries + one. +- **The button reports its own outcome.** Lit while Remote Control is on, red with the reason in its tooltip + when the request was refused — Remote Control has to be enabled for your account, and on Team and + Enterprise plans by an organisation Owner as well. Neither control turns itself on until the binary has + said yes. + ## [5.7.0] — 2026-08-21 **The guard is now something you can see, tune and audit**, instead of a set of rules that only spoke up to diff --git a/README.md b/README.md index 15ab36b1..ab053f49 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Claude Code Native -[![Version](https://img.shields.io/badge/version-5.7.0-E07B5A)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-5.8.0-E07B5A)](CHANGELOG.md) [![IDE](https://img.shields.io/badge/JetBrains-2025.3.1%20%E2%86%92%20263.*-000000?logo=jetbrains)](#requirements) [![Marketplace](https://img.shields.io/badge/Marketplace-Claude%20Code%20Native-2A2A2A)](https://plugins.jetbrains.com/plugin/31965-claude-code-native) [![License](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) @@ -355,6 +355,29 @@ on your disk is ever removed, and nothing you authored is. A chat that needs you while you are looking elsewhere — a permission, a finished turn, an error — raises a notification and badges its tab. Suppressed for the chat already on screen. +### Remote Control — pick a chat up from your phone + +The **phone button** in the chat's button row, left of the guard's shield, connects that chat to +[claude.ai/code](https://claude.ai/code) or the Claude mobile app. The same switch lives in the ⚙ menu +under **Remote control**. Claude keeps running on your machine the whole time: your files, your MCP +servers and your project configuration stay the ones in use, and the browser or the phone is a second +window onto the conversation already open in the IDE. When the connection carries a session URL, the +chat prints it. + +Messages go both ways, and so do permissions — a call you approve from the phone stops asking here, and +its card leaves the chat by itself. The **Sensitive Guard still decides first**, on this machine, before +anything is offered to anyone to approve: connecting a phone does not widen what a chat is allowed to +do, only who can answer it. That is worth being deliberate about, because the person tapping *Approve* +is no longer necessarily sitting in front of the diff. + +**It has to be enabled for your Claude account**, and on Team and Enterprise plans an organisation Owner +has to turn it on first; the feature needs a claude.ai subscription, so an API key will not do. When the +request is refused the button turns red, its tooltip carries the reason, and the same reason is written +into the chat. Neither control switches itself on until the CLI has said yes, so a lit button means a +live connection. + +Turning it off, from either control, disconnects the remote session and leaves the chat running here. + ### Git The **Git** button in the tool window's title bar opens a chat dedicated to the integration, and with it @@ -542,7 +565,7 @@ wrapper is included. ```bash JAVA_HOME=/path/to/a/jdk-21 ./gradlew buildPlugin -# → build/distributions/claude-code-native-5.7.0.zip +# → build/distributions/claude-code-native-5.8.0.zip ``` Install it with **Settings ▸ Plugins ▸ ⚙ ▸ Install Plugin from Disk**. @@ -611,6 +634,12 @@ Architecture, protocol details and the empirically verified facts about the bina ## What's new +**5.8.0** — [Remote Control](#remote-control--pick-a-chat-up-from-your-phone): a chat running in the IDE +can be driven from claude.ai or the Claude mobile app while Claude keeps executing on your machine. A +phone button in the chat's button row and a row in the ⚙ menu, neither of which lights up until the CLI +has accepted; a refusal names its reason on the button and in the chat. Permission cards answered from +the phone close themselves here. + **5.5.0** — a tab and a transcript per agent, with the whole tree one hover away; a single **Workloads** diagram of everything running across every chat; background tasks that keep their output after they end and survive a restart; a [Git integration](#git) whose write actions are asked of Claude rather than diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2b95fe41..9feca591 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,18 @@ +## v5.8.0 — 2026-08-28 + +**Pick a chat up from your phone.** Remote Control connects a chat running in your IDE to +claude.ai/code or the Claude mobile app, and Claude keeps running on your machine the whole +time — your filesystem, your MCP servers and your project configuration stay the ones in use. +Start a task at the desk, follow it or steer it from the couch, and carry on in the IDE. + +**Two ways in, and neither lies to you.** A phone button sits in the chat's button row, left of +the guard's shield, and *Remote control* is in the ⚙ menu. Both wait for the binary before they +light up, so a control that looks on is on. If the request is refused the button turns red and +its tooltip says why, with the same reason spelled out in the chat. + +**What it needs.** Remote Control has to be enabled for your Claude account, and on Team and +Enterprise plans an organisation Owner has to turn it on first. + ## v5.7.0 — 2026-08-21 **The Sensitive Guard stops being invisible.** It keeps a log of every alert it raises — in the IDE's diff --git a/build.gradle.kts b/build.gradle.kts index 3f14fcf5..595a0329 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,7 +28,7 @@ plugins { } group = "dev.lain" -version = "5.7.0" +version = "5.8.0" repositories { mavenCentral() diff --git a/package-lock.json b/package-lock.json index 623e6a7b..8f26e531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,23 +22,23 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.233", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.233.tgz", - "integrity": "sha512-Dy+YqhggwtbezDy3Ap2pb1sK3bOqnI+sLNnsVjB3AUWvR0QlGnjjrjORXY03Y50I+B1eFRNEcYPAZKRYlCkSLQ==", + "version": "0.3.250", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.250.tgz", + "integrity": "sha512-qT/1cBZs0+xPsQfqVOnwIk6pNW8XBkTpQS5RAXKHYb2XYCKqYc0UmOaeiYU2WeI6HEZKORa5iCaAZyKWGluShw==", "dev": true, "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.233", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.233", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.233", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.233", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.233", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.233", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.233", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.233" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.250", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.250", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.250", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.250", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.250", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.250", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.250", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.250" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -47,9 +47,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.233", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.233.tgz", - "integrity": "sha512-4WDiBZgcrmvTDJjS8RNZwoxGgMz/0EpOM+sYa6EtyjwHTd6It1H/+k5zBckCmBajbgS5/ASCJqdwZzi7dwBl0Q==", + "version": "0.3.250", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.250.tgz", + "integrity": "sha512-tcekW4gR2UH0Q3COBaNPQIdud2lKEbs0HfG2yNKC18hXFPpgbuLCdjq0ndS1lcvC1q8ncPW3oQPUutQt3StICQ==", "cpu": [ "arm64" ], @@ -61,9 +61,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.233", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.233.tgz", - "integrity": "sha512-RaaEfNrbqSh77H5NdVF9cJQ0xhAUO92aOv71LSKSdAYModMeUvJN0k22Q7gvmx0TlmqJ+aVyCG8J8gVfgSL9mg==", + "version": "0.3.250", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.250.tgz", + "integrity": "sha512-8Yxmmi76oVEIam+oRgxcL2RtqEkKX9Gp4rh500HmMltjX3Tk/ryjCoJEHoaUdU/LU6vWvfQU5W+dB/SJCQQb2A==", "cpu": [ "x64" ], @@ -75,9 +75,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.233", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.233.tgz", - "integrity": "sha512-Az9HjQthYQqRjJCacBtDIAHX3TRGK9WlACNb/UOGAK3JndNzZMprM2mK/t6YmP2cRLJsGyorxL7HZmR9R9HYaw==", + "version": "0.3.250", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.250.tgz", + "integrity": "sha512-exABrKfqDv9rp6UCqOTjd4rH1lH6YZt0IuGFsAEHg52N08i5+4/uRtUlQzMf0VpN/dgqDoxt5jpOB9hH863r9w==", "cpu": [ "arm64" ], @@ -92,9 +92,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.233", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.233.tgz", - "integrity": "sha512-Z3uZdzt6xgJ3f4NIgO6lzBYSELULKSq6AL4OsNLBzuaEpVW0iYs1kUCaD9rcMlMrf3cV+Dk/GA/lTCGMgbucjQ==", + "version": "0.3.250", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.250.tgz", + "integrity": "sha512-wpe2UmFrC2wyR+DbG6zIiRmqiFvK8nJjdi++nf3jNn0ZSHvW2BqPrh+JnPJOcmkAYR58IdMIj2jagbQAGtN/rA==", "cpu": [ "arm64" ], @@ -109,9 +109,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.233", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.233.tgz", - "integrity": "sha512-jpbhV+n9PnxLiyheQ/HjtHIg/E5/jVsk2Vdu132BSoL/3bsObSmMqKgsqoMutzwRZvtpqRs2RPVcjsC8G4A9Zw==", + "version": "0.3.250", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.250.tgz", + "integrity": "sha512-skqoeRDEjobqtIXmt9GNWJUyjcSvKHEf5QVf4PF9v37CtkJWEt7Hs64yFkahFD1mY34FAUyy/Ij0NrbeGKHPBg==", "cpu": [ "x64" ], @@ -126,9 +126,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.233", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.233.tgz", - "integrity": "sha512-kYBIAQCu2f1YITcGbpUN2jfrkAzs59TVAragAhE2z+GrkIcxcpZwmaRY6heMBtaSY8SuyrwgqbCW9hJALYFnEg==", + "version": "0.3.250", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.250.tgz", + "integrity": "sha512-ZMjXn2r00SlS17ozg6RfK+OF7kPo3COrCUTPrKnXZk7AYZ+hE0xqunrkt66LwYNFc+KlvDTAZZHSiZVblTT7rA==", "cpu": [ "x64" ], @@ -143,9 +143,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.233", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.233.tgz", - "integrity": "sha512-aO2MaNdmQofyPLKszE4s+Ope/sLJPeI/ZlGdCcjYp7qhji2hgZ4bRWWsOrx5eKjz0gFK5CFFltILkFcNcxCsVg==", + "version": "0.3.250", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.250.tgz", + "integrity": "sha512-PC3pzcV8/bT9+PAM9k4fGMU64YY0F9NV6f/G4vbLMqz4qZAWqQp9AXMea+F5KULeJG2ygaAhmhcDyOzW77HhEw==", "cpu": [ "arm64" ], @@ -157,9 +157,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.233", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.233.tgz", - "integrity": "sha512-TcAYyWPXS5mREZGUksuCZsLIRQjbo/Vriur2PqIhAmgZ1oiqBZO27a90sX60EUczD7yV8wpwOVhVLhUxO0kAEg==", + "version": "0.3.250", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.250.tgz", + "integrity": "sha512-PjJRbJwDHccSUWls5gTiuXMgERit1WrrMQzzRqhhBHGzrlQueHVodrpg7HaN5gtirADJzfINcc7azq8j3qcEYw==", "cpu": [ "x64" ], diff --git a/scripts/drift-baseline.properties b/scripts/drift-baseline.properties index 6c8269db..abc53e17 100644 --- a/scripts/drift-baseline.properties +++ b/scripts/drift-baseline.properties @@ -5,5 +5,5 @@ # These are the last *reconciled* versions: the detector updates the live tools to latest (npm update + # claude --update), then reports any protocol surface the plugin doesn't model yet. After reconciling a # report (and `npm update` having bumped the vendored SDK), bump these to match. -sdk=0.3.233 -binary=2.1.233 +sdk=0.3.250 +binary=2.1.250 diff --git a/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt b/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt index 5a4c7241..d7b644cb 100644 --- a/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt +++ b/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt @@ -78,6 +78,8 @@ sealed interface ClaudeEvent { data class UnsupportedControlRequest(val requestId: String, val subtype: String?) : Control + data class ControlCancel(val requestId: String) : Control + data class ControlResult( val requestId: String, val success: Boolean, diff --git a/src/main/kotlin/dev/lain/claudejb/protocol/ProtocolParser.kt b/src/main/kotlin/dev/lain/claudejb/protocol/ProtocolParser.kt index 04e5cc45..aaee9c87 100644 --- a/src/main/kotlin/dev/lain/claudejb/protocol/ProtocolParser.kt +++ b/src/main/kotlin/dev/lain/claudejb/protocol/ProtocolParser.kt @@ -18,6 +18,7 @@ object ProtocolParser { put("stream_event", ::parseStreamEvent) put("control_request", ::parseControlRequest) put("control_response", ::parseControlResponse) + put("control_cancel_request", ::parseControlCancel) put("rate_limit_event", ::parseRateLimit) put("keep_alive") { emptyList() } typed("result", ResultMessage.serializer(), ClaudeEvent::Result) @@ -252,6 +253,11 @@ object ProtocolParser { }.getOrDefault(emptyList()) } + private fun parseControlCancel(root: JsonObject): List { + val requestId = root.str("request_id") ?: return emptyList() + return listOf(ClaudeEvent.ControlCancel(requestId)) + } + private fun parseControlResponse(root: JsonObject): List { val response = root["response"] as? JsonObject ?: return emptyList() val requestId = response.str("request_id") ?: return emptyList() diff --git a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt index 90fceb18..cde3e251 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt @@ -313,6 +313,10 @@ class ClaudeSession( private set var account: AccountInfo = AccountInfo() private set + var remoteControlEnabled: Boolean = false + private set + var remoteControlError: String? = null + private set @Volatile private var process: ClaudeProcess? = null @@ -768,6 +772,32 @@ class ClaudeSession( } } + fun setRemoteControl(enabled: Boolean, onSettled: () -> Unit) { + queries.setRemoteControl(enabled) { outcome -> + if (outcome.ok) remoteControlEnabled = outcome.enabled + remoteControlError = if (outcome.ok) null else remoteControlRefusal(outcome) + fireState() + transcript.add(Speaker.SYSTEM, remoteControlNotice(outcome)) + onSettled() + } + } + + private fun remoteControlRefusal(outcome: RemoteControlOutcome): String { + val what = if (outcome.enabled) "switch Remote Control on" else "switch Remote Control off" + return outcome.error?.takeIf { it.isNotBlank() }?.let { "Could not $what: $it" } ?: "Could not $what." + } + + private fun remoteControlNotice(outcome: RemoteControlOutcome): String = when { + !outcome.ok -> remoteControlRefusal(outcome) + + " Remote Control has to be enabled for your account, and by your organisation on Team and Enterprise plans." + + !outcome.enabled -> "Remote Control is off. This chat keeps running in the IDE." + + outcome.sessionUrl != null -> "Remote Control is on — ${outcome.sessionUrl}" + + else -> "Remote Control is on. The session is listed at https://claude.ai/code" + } + fun removeQueued(index: Int) = edt { if (index in queue.indices) { val copy = queue.toMutableList() @@ -1184,6 +1214,8 @@ class ClaudeSession( is ClaudeEvent.UnsupportedControlRequest -> broker.rejectUnsupported(event.requestId, event.subtype) + is ClaudeEvent.ControlCancel -> edt { cards.withdraw(event.requestId) } + is ClaudeEvent.ControlResult -> controlClient.onControlResult(event) } } diff --git a/src/main/kotlin/dev/lain/claudejb/session/ControlAsks.kt b/src/main/kotlin/dev/lain/claudejb/session/ControlAsks.kt index 01dbe8ee..fd017bfc 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/ControlAsks.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/ControlAsks.kt @@ -19,6 +19,18 @@ import kotlinx.serialization.json.put data class RewindResult(val canRewind: Boolean, val error: String?, val filesChanged: List) +data class RemoteControlOutcome( + val enabled: Boolean, + val ok: Boolean, + val sessionUrl: String?, + val error: String?, +) + +private val CLAUDE_SESSION_URL = Regex("""https://claude\.ai/[A-Za-z0-9./_#?=&-]+""") + +internal fun sessionUrlIn(payload: JsonObject?): String? = + payload?.let { CLAUDE_SESSION_URL.find(it.toString())?.value } + @Serializable data class PlanInfo( val exists: Boolean? = null, @@ -144,6 +156,12 @@ object Asks { }, ) + fun remoteControl(enabled: Boolean) = Ask( + subtype = "remote_control", + params = { put("enabled", enabled) }, + decode = { it }, + ) + fun rewind(userMessageId: String, dryRun: Boolean) = Ask( subtype = "rewind_files", params = { diff --git a/src/main/kotlin/dev/lain/claudejb/session/SessionCards.kt b/src/main/kotlin/dev/lain/claudejb/session/SessionCards.kt index f749704f..cbe4ee1e 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/SessionCards.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/SessionCards.kt @@ -77,6 +77,13 @@ class SessionCards( session.systemNotice("Rejected ${request.headline}") } + fun withdraw(requestId: String) { + val request = session.cardManager.remove(requestId) ?: return + session.diffs.closeReviewDiff(requestId) + session.systemNotice("${request.headline} was answered from a connected device.") + firePermissions() + } + fun resolveQuestion(requestId: String, answers: Map) { val request = session.cardManager.remove(requestId) ?: return val updated = buildJsonObject { diff --git a/src/main/kotlin/dev/lain/claudejb/session/SessionQueries.kt b/src/main/kotlin/dev/lain/claudejb/session/SessionQueries.kt index 6cf83c25..ab9017a0 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/SessionQueries.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/SessionQueries.kt @@ -46,6 +46,17 @@ class SessionQueries( fun requestRewindFiles(userMessageId: String, dryRun: Boolean, onResult: (RewindResult?) -> Unit) = ask(Asks.rewind(userMessageId, dryRun), onResult) + fun setRemoteControl(enabled: Boolean, onResult: (RemoteControlOutcome) -> Unit) { + val ask = Asks.remoteControl(enabled) + if (!isRunning()) { + edt { onResult(RemoteControlOutcome(enabled, ok = false, sessionUrl = null, error = "the session is not running")) } + return + } + controlClient.send({ id -> ControlProtocol.of(id, ask.subtype, ask.params) }) { res -> + edt { onResult(RemoteControlOutcome(enabled, res.success, sessionUrlIn(res.payload), res.error)) } + } + } + fun reconnectMcp(name: String) = fireAndForget { id -> ControlProtocol.mcpReconnectRequest(id, name) } fun toggleMcp(name: String, enabled: Boolean) = fireAndForget { id -> ControlProtocol.mcpToggleRequest(id, name, enabled) } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/ChatBridgeRouter.kt b/src/main/kotlin/dev/lain/claudejb/ui/ChatBridgeRouter.kt index 274f8603..c7eb9e84 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/ChatBridgeRouter.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/ChatBridgeRouter.kt @@ -127,6 +127,10 @@ internal class ChatBridgeRouter(private val panel: JcefChatPanel) { if (!m.on) session.guardApprovals.revoke(rule, command) return true } + if (JcefSettingsMenu.isRemoteControl(m.key)) { + session.setRemoteControl(m.on) { JcefChatPanel.pushSettingsMenuToAll() } + return true + } val models = session.models.map { it.value } var known = false settings.update { known = JcefSettingsMenu.apply(settings.scope.id, it, m.key, m.on, models) } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSettingsMenu.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSettingsMenu.kt index 98faa888..d78da930 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSettingsMenu.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSettingsMenu.kt @@ -24,6 +24,7 @@ internal object JcefSettingsMenu { val effort: String?, val mode: String, val approvals: Map> = emptyMap(), + val remoteControl: Boolean = false, ) fun json(scope: String, state: ClaudeSettings.State, session: ClaudeSession): JsonArray = @@ -33,6 +34,7 @@ internal object JcefSettingsMenu { modelRows(selected) effortRows(selected) modeRows(selected) + remoteControlRows(selected) chatRows(state) securityRows(scope, state) sessionApprovalRows(selected.approvals) @@ -62,6 +64,8 @@ internal object JcefSettingsMenu { } } + fun isRemoteControl(key: String): Boolean = key == REMOTE_CONTROL + fun alwaysAllowTool(key: String): String? { if (!key.startsWith("$ALWAYS:")) return null return key.removePrefix("$ALWAYS:").takeIf { it in ToolNaming.BUILTIN_TOOLS } @@ -87,6 +91,16 @@ internal object JcefSettingsMenu { } } + private fun JsonArrayBuilder.remoteControlRows(selected: Selected) { + entry( + REMOTE_CONTROL, + "Remote control", + "Drive this chat from claude.ai", + selected.remoteControl, + hostOwned = true, + ) + } + private fun JsonArrayBuilder.chatRows(s: ClaudeSettings.State) { entry("restoreChats", "Chat", "Restore open chats on startup", s.restoreOpenChatsOnStartup) entry("reduceMotion", "Chat", "Reduce motion", s.reduceMotion) @@ -157,6 +171,7 @@ internal object JcefSettingsMenu { radio: Boolean = false, deferred: Boolean = false, sub: String? = null, + hostOwned: Boolean = false, ) = addJsonObject { put("key", key) put("group", group) @@ -165,6 +180,7 @@ internal object JcefSettingsMenu { put("on", on) put("type", if (radio) TYPE_RADIO else TYPE_CHECK) put("deferred", deferred) + if (hostOwned) put("hostOwned", true) } private val FLAG_SETTERS: Map Unit> = mapOf( @@ -279,8 +295,11 @@ internal object JcefSettingsMenu { effort = session.effort, mode = session.permissionMode, approvals = session.guardApprovals.all(), + remoteControl = session.remoteControlEnabled, ) + internal const val REMOTE_CONTROL = "remoteControl" + private const val APPROVAL = "approval" private const val GUARD_MODE = "guardmode" private const val MODEL = "model" diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt index 25a7bf78..81779640 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt @@ -61,6 +61,8 @@ object JcefState { } put("guardOn", session.guardEnforced) + put("remoteControlOn", session.remoteControlEnabled) + put("remoteControlError", session.remoteControlError) put("provider", JcefComposerOptions.providerJson(provider)) put("model", JcefComposerOptions.modelJson(session)) diff --git a/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt b/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt index cdc4d732..73b40a02 100644 --- a/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt +++ b/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt @@ -2,7 +2,7 @@ package dev.lain.claudejb.util object PluginIdentity { - const val PLUGIN_VERSION = "5.7.0" + const val PLUGIN_VERSION = "5.8.0" private const val PROJECT_URL = "https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains" diff --git a/src/main/resources/jcef/app-composer-settings.js b/src/main/resources/jcef/app-composer-settings.js index 46ec4f5f..65b3887b 100644 --- a/src/main/resources/jcef/app-composer-settings.js +++ b/src/main/resources/jcef/app-composer-settings.js @@ -177,6 +177,10 @@ if (CC.announce) CC.announce(labelOf(it) + ' selected'); return; } + if (it.hostOwned) { + CC.send({ type: 'settingsToggle', key: String(it.key), on: !already }); + return; + } it.on = !already; applyState(row, !already); CC.send({ type: 'settingsToggle', key: String(it.key), on: !already }); diff --git a/src/main/resources/jcef/app-composer.js b/src/main/resources/jcef/app-composer.js index 0c92c9b7..c54191f5 100644 --- a/src/main/resources/jcef/app-composer.js +++ b/src/main/resources/jcef/app-composer.js @@ -15,6 +15,9 @@ var followBtnRef = null; var guardOn = true; var guardBtnRef = null; + var rcOn = false; + var rcError = null; + var rcBtnRef = null; function applyFollow() { if (followBtnRef) { @@ -67,6 +70,32 @@ applyGuard(); }; + function phoneGlyph() { + return ( + '' + ); + } + + function applyRemoteControl() { + if (!rcBtnRef) return; + rcBtnRef.classList.toggle('active', rcOn); + rcBtnRef.classList.toggle('failed', !rcOn && !!rcError); + if (!rcOn && rcError) rcBtnRef.title = rcError; + else if (rcOn) rcBtnRef.title = 'Remote Control is on — click to disconnect this chat from claude.ai'; + else rcBtnRef.title = 'Remote Control is off — click to drive this chat from claude.ai'; + } + + CX.setRemoteControlOn = function (on, error) { + var next = on === true; + var nextError = typeof error === 'string' && error ? error : null; + if (next === rcOn && nextError === rcError) return; + rcOn = next; + rcError = nextError; + applyRemoteControl(); + }; + function ensureBuilt() { if (built) return true; if (!CC.els || !CC.els.composer) return false; @@ -163,7 +192,23 @@ }); guardBtn.innerHTML = guardGlyph(); guardBtnRef = guardBtn; - var barRight = h('div', { class: 'bar-right' }, guardBtn, followBtn, vibeBtn, sendBtn); + + var rcBtn = h('button', { + class: 'bar-icon', + attrs: { type: 'button', 'aria-label': 'Remote Control' }, + on: { + click: function (e) { + e.preventDefault(); + e.stopPropagation(); + send({ type: 'settingsToggle', key: 'remoteControl', on: !rcOn }); + }, + }, + }); + rcBtn.innerHTML = phoneGlyph(); + rcBtnRef = rcBtn; + applyRemoteControl(); + + var barRight = h('div', { class: 'bar-right' }, rcBtn, guardBtn, followBtn, vibeBtn, sendBtn); var bar = h('div', { class: 'composer-bar' }, barLeft, barRight); var guardMenu = CC.durationMenu({ @@ -441,6 +486,7 @@ if (!s) return; announceTurnState(s); CX.setGuardOn(s.guardOn); + CX.setRemoteControlOn(s.remoteControlOn, s.remoteControlError); CX.renderAuth(s); renderSendMode(s); CX.renderPills(s); diff --git a/src/main/resources/jcef/css/composer.css b/src/main/resources/jcef/css/composer.css index ad39fbaa..344086ef 100644 --- a/src/main/resources/jcef/css/composer.css +++ b/src/main/resources/jcef/css/composer.css @@ -294,6 +294,9 @@ .bar-icon.active { color: var(--accent); } +.bar-icon.failed { + color: var(--danger); +} .bar-icon .vibe-emoji { font-size: 16px; line-height: 1; diff --git a/src/test/kotlin/dev/lain/claudejb/drift/ProtocolSurface.kt b/src/test/kotlin/dev/lain/claudejb/drift/ProtocolSurface.kt index 51bb0d0a..003cc09c 100644 --- a/src/test/kotlin/dev/lain/claudejb/drift/ProtocolSurface.kt +++ b/src/test/kotlin/dev/lain/claudejb/drift/ProtocolSurface.kt @@ -49,7 +49,7 @@ data class ProtocolSurface( val KNOWN_EVENT_TYPES: Set = setOf( "system", "assistant", "user", "stream_event", "result", - "control_request", "control_response", "rate_limit_event", "keep_alive", + "control_request", "control_response", "control_cancel_request", "rate_limit_event", "keep_alive", "auth_status", "tool_progress", "tool_use_summary", "prompt_suggestion", ) diff --git a/src/test/kotlin/dev/lain/claudejb/ui/jcef/JcefSettingsMenuTest.kt b/src/test/kotlin/dev/lain/claudejb/ui/jcef/JcefSettingsMenuTest.kt index e307680c..c5ee809f 100644 --- a/src/test/kotlin/dev/lain/claudejb/ui/jcef/JcefSettingsMenuTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/ui/jcef/JcefSettingsMenuTest.kt @@ -46,7 +46,7 @@ class JcefSettingsMenuTest { fun `the groups are drawn in the declared order`() { assertEquals( listOf( - "Model", "Effort", "Permission mode", "Chat", "Guard mode", "Security", + "Model", "Effort", "Permission mode", "Remote control", "Chat", "Guard mode", "Security", "Setting sources", "Allowed tools", "Disallowed tools", "Always allowed tools", "MCP", ), menu().map { it.str("group") }.distinct(), @@ -63,7 +63,7 @@ class JcefSettingsMenuTest { assertTrue(rows.none { it.bool("deferred") }, "$group takes effect on the live session") } - listOf("Chat", "Security", "Always allowed tools").forEach { group -> + listOf("Chat", "Remote control", "Security", "Always allowed tools").forEach { group -> val rows = byGroup.getValue(group) assertTrue(rows.all { it.str("type") == "check" }, "$group must be a checkbox group") assertTrue(rows.none { it.bool("deferred") }, "$group takes effect immediately") @@ -115,6 +115,7 @@ class JcefSettingsMenuTest { menu().forEach { row -> val key = row.str("key") val accepted = JcefSettingsMenu.alwaysAllowTool(key) != null || + JcefSettingsMenu.isRemoteControl(key) || write(ClaudeSettings.State(), key, true) assertTrue(accepted, "no destination accepts the menu key '$key'") }