Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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**.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ plugins {
}

group = "dev.lain"
version = "5.7.0"
version = "5.8.0"

repositories {
mavenCentral()
Expand Down
70 changes: 35 additions & 35 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions scripts/drift-baseline.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -252,6 +253,11 @@ object ProtocolParser {
}.getOrDefault(emptyList())
}

private fun parseControlCancel(root: JsonObject): List<ClaudeEvent> {
val requestId = root.str("request_id") ?: return emptyList()
return listOf(ClaudeEvent.ControlCancel(requestId))
}

private fun parseControlResponse(root: JsonObject): List<ClaudeEvent> {
val response = root["response"] as? JsonObject ?: return emptyList()
val requestId = response.str("request_id") ?: return emptyList()
Expand Down
32 changes: 32 additions & 0 deletions src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/main/kotlin/dev/lain/claudejb/session/ControlAsks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ import kotlinx.serialization.json.put

data class RewindResult(val canRewind: Boolean, val error: String?, val filesChanged: List<String>)

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,
Expand Down Expand Up @@ -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 = {
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/dev/lain/claudejb/session/SessionCards.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>) {
val request = session.cardManager.remove(requestId) ?: return
val updated = buildJsonObject {
Expand Down
Loading