diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74fd102f..b9ee0047 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,45 @@ 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.0.1] — 2026-08-10
+
+### Fixed
+- **The subscription login did not survive a restart.** The credential was stored correctly — in the IDE's
+ PasswordSafe, which resolves to the OS store — KWallet or GNOME Keyring through the Secret Service on
+ Linux, the Keychain on macOS, the Credential Manager on Windows — and it was still there after the reboot,
+ confirmed by reading the entry back out of the OS store directly. What expired was the *access
+ token* inside it: the OAuth flow issues one good for hours (~10 h, measured), so any restart the next day
+ found a perfectly persisted credential that no longer authenticated anything. `hasUsableToken()` answered
+ false, and false meant "signed out", so the sign-in card came back every morning.
+
+ The blob beside it always carried a **refresh token valid for weeks** and the plugin never spent it, by
+ design: only the binary can, and it does so by rewriting `~/.claude/.credentials.json` — the exact file the
+ vault exists to remove. The way out is that the binary has a **non-interactive** login for precisely this:
+ given `CLAUDE_CODE_OAUTH_REFRESH_TOKEN` and `CLAUDE_CODE_OAUTH_SCOPES`, `claude auth login` takes a
+ dedicated branch, mints a fresh credential and exits — no browser, no TTY, no user. So renewal is now the
+ binary's job, exactly as it always was, and the plugin's job stays what it was: take custody of the result
+ and delete the plaintext copy. No OAuth client here, no token endpoint called from the IDE, no file written
+ back — the invariant `NoFileDeletionContractTest` and the vault's KDoc both state is untouched.
+
+ Reported on **Linux and Windows**, and it is one bug rather than two: the binary's default credential store
+ is its `plaintext` provider (`~/.claude/.credentials.json`) on every platform, so the vault takes custody
+ the same way everywhere and the token expires the same way everywhere. The fix carries no platform-specific
+ code — the only Windows-specific care is that the renewal environment strips `CLAUDE_CODE_OAUTH_TOKEN`
+ case-insensitively, since environment names are case-insensitive there.
+
+ **Scope: the subscription (OAuth) credential only.** An Anthropic API key is a different identity in a
+ different slot — `providerApiKey:anthropic` in the same PasswordSafe, not `CLAUDE_CREDENTIALS_JSON` — and it
+ has no expiry and no refresh token, so there was nothing to lose across a restart and there is nothing to
+ renew now. `CredentialsVault.renew()` reads the `claudeAiOauth` blob and nothing else, and `envOverlay`
+ withdraws entirely when an API key is present, so an API-key session is untouched by any of this.
+
+ An expired-but-renewable credential now counts as an identity (`CredentialsVault.canRenew`), the renewal
+ runs off the EDT at launch (`ClaudeSession.renewVaultedCredential`, before the launch env is built, and
+ never while a sign-in is in flight), the refresh token rotates at every renewal so ordinary use extends it
+ indefinitely, and a failed renewal arms a five-minute cooldown so the three-second boot watcher cannot turn
+ a flaky network into a process spawn per poll. Sign-in is now needed only after a genuinely idle period, or
+ when Anthropic invalidates the grant.
+
## [5.0.0] — 2026-08-05
The standards-compliance major. The repository was taken through the standards catalogue domain by domain —
diff --git a/CLAUDE.md b/CLAUDE.md
index 1a81eb8f..b1563c7f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,7 +64,7 @@ Build: `JAVA_HOME=~/.jdks/jbr-21.0.11 ./gradlew buildPlugin` → zip in `build/d
**Guideline — always latest, zero deprecations:** keep platform/Gradle/Kotlin/deps on the newest stable, widen `untilBuild` to the current EAP/RC, and **never ship a deprecated or scheduled-for-removal API**. If `verifyPlugin` flags one, migrate it before release — treat it as a blocker, not a warning. Everything up to date, always.
## Status
-Package `dev.lain.claudejb`, plugin id `dev.lain.claude-code-for-jetbrains`, name **"Claude Code Native"**, version **5.0.0**, compatibility **251 → latest EAP/RC** (compiled against IC 2025.2; floor lowered from 252 in 4.3.1 — 251 is as far back as the API reaches with ZERO deprecations: `FileChooserDescriptorFactory.multiFiles()/singleDir()` in `FilePickerHelper` does not exist on 242/243, and its pre-251 equivalent is deprecated on current IDEs. Verified offline against locally-extracted IDEs via `-PlocalIdePath=
[,…]`, which now takes a comma-separated list). **5.0.0 = the standards-compliance major.** The repository was put through the standards catalogue domain by domain, and the major reflects that the *code* changed, not just the docs. It did NOT stay purely that: it also ships the **plan-limits panel** (`get_usage`, a control request known since 4.0.1 and never sent — all rate-limit windows plus the extra-credit balance, as dashboard bars and composer dots, blue <65% / amber <85% / red above, announced once per threshold per window) and a run of user-facing fixes, the largest being a **tab-killing NPE this branch itself introduced**: `JcefChatPanel.pendingUntilReady` was declared BELOW the `init` block that uses it, and Kotlin runs property initializers and `init` blocks in declaration order — so it was null inside the constructor and NO chat could be opened or restored. `lastUsage`/`lastUsageAt` had the same defect and stayed silent (nullable/primitive read as null/0), which is why `InitOrderContractTest` now scans the sources: the compiler only flags a *direct* reference in an initializer, not one made through a function called from `init`. Also from that pass: a **boot screen** (the binary is launched BEFORE the tab is built, since `start()` only dispatches; FOUR states — running / starting / **binaryMissing** (the install-or-path onboarding card) / neither, that last being a launch that failed for another reason and MUST clear the screen); context and cost polled on ready, tab-open and both turn edges instead of waiting out a `javax.swing.Timer` whose initial delay equals its 60s interval (and the timer now retires at turn end — those numbers cannot move while idle); the CLI's `` wrapper stripped in `ProtocolParser.unwrapToolError` (verified in 2.1.222, which carries the same text unwrapped in a sibling field — rendering it verbatim put raw markup in a native GUI); failed tool cards auto-open once and wrap their error text (collapsed, the whole message was "the header is red"); `ToolSearch` + `AskUserQuestion`/`Mcp`/`FileRead`/`FileEdit`/`FileWrite` added to `SensitiveGuard.AGENT_TOOLS` — **that list is only ever appended to**, it is a trust allowlist and not an inventory, and `ToolSearch` was the load-bearing gap (it loads every deferred tool's schema, so on a session that defers them the call unlocking all the others was landing in the untrusted branch); and markdown links whose href is a path now open (`LinkResolver.isFilePathHref`, with a two-or-more-character scheme test so a Windows drive stays a path) through the same `isOpenable` gate as `jb://`. (1) **Dependency scope corrected** — `@anthropic-ai/claude-agent-sdk` sat in `dependencies` while it is protocol reference only, producing 7 permanent npm-audit findings (3 high) against code no user receives; moved to `devDependencies` (`npm audit --omit=dev` → 0), `checkDrift` verified green across the move (it reads the SDK from `node_modules` and runs `npm update`; only `--omit=dev` would break it) and re-baselined to `claude` 2.1.222 / SDK 0.3.222. `package.json` also declared `"license": "ISC"` on a GPL-3.0-only repo and was missing `"private": true` — i.e. publishable to npm under the wrong licence. (2) **`LoginCoordinator` extracted** from `ClaudeSession` (1965 → 1826 lines) — the OAuth subsystem and its three state fields; mechanical, no behaviour change, 677 tests green across it. The other two extractions the plan proposed (`SessionRestorer`, `RewindCoordinator`) were **deliberately not done**: `restore` is 23 lines that write six pieces of session state, and rewind is one of six identically-shaped `controlClient.query` delegates — extracting either buys indirection, not cohesion. (3) **Accessibility** — WCAG 4.1.3 live region (`#a11y-status`, declared in the static shell so the first write is announced) + `CC.announce`, and a `:focus-visible` baseline with a `forced-colors` fallback, pinned by 10 frontend tests; the EU Accessibility Act has applied since 28-jun-2025. (4) **Attribution ships inside the artifact** (`THIRD-PARTY-NOTICES.md`, `LICENSE`, `LICENSES/*` under `META-INF/`) — a permissive licence's notice obligation binds on *redistribution*, and the plugin redistributes marked/DOMPurify/highlight.js. (5) **Governance**: commitlint + a versioned `.githooks/commit-msg` that degrades to advisory if the toolchain fails (so it never becomes a reason to reach for `--no-verify`), `.gitattributes`, and three ADRs — [0001](docs/adr/0001-release-process.md) release process (GitFlow and GPG-on-YubiKey as *recorded deviations*, tag immutability as a **correction**: `v4.3.2` and `v4.4.1` were each force-re-cut three times, which is exactly what a signature is supposed to prevent; plus the generated-CHANGELOG deferral with a one-command exit test), [0002](docs/adr/0002-threat-model.md) threat model (trust model + STRIDE over binary/MCP/model-content; prompt injection is **assumed to succeed**, not detected), [0003](docs/adr/0003-i18n-deferred.md) i18n deferred with its triggers. **4.4.1 = `/login` terminal launch fixed (REAL regression, silent).** Every platform API `TerminalLauncher` reflected on was missing at runtime: the Reworked path looked up `com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager`, which is NOT in the shipped IDE at all (scanned every jar of IU-262.8665.337), and the Classic path used `TerminalToolWindowManager.createShellWidget(…)`/`.createLocalShellWidget(…)`, present on 251/252 but REMOVED by 262. Each lookup returns false rather than throwing → totally silent, nothing in idea.log, `/login` always landed on the "run it yourself" notice. Fix: `createNewSession(workingDirectory, tabName, shellCommand, requestFocus, deferSessionStartUntilUiShown)`, verified by hand on 251+252+262, with the login passed as **argv** (`TerminalLauncher.loginArgv`) not a shell string — killing the quoting hazard (Windows `&` prefix, spaces) and the send-into-a-shell race at once. **Why CI missed it:** the plugin compiles/tests against IC-2025.2, where the removed factories still exist — the break only manifests at 262+, so `TerminalApiContractTest` pins the replacement against the build classpath and `verifyPlugin`'s range run is the complementary half. Also wired `ClaudeLoginFlow` (pty4j) in as a REAL fallback — it was unreachable code, since `startLogin()` called the terminal unconditionally — so order is now terminal → native PTY → manual notice; and fixed a latent bug there: pty4j REPLACES the child env wholesale (unlike `ClaudeProcess`, which inherits via `withParentEnvironmentType(CONSOLE)`), so `System.getenv()` must be merged in or the spawned binary loses `PATH`/`HOME`. **4.4.0 = per-rule security toggles + `AGENT_TOOLS` allowlist fix.** Each `SensitiveGuard` rule (CREDENTIAL, DANGEROUS_COMMAND, and FOREIGN split into its three sub-rules via `ForeignReason`) is independently switchable via five `Policy.enforce*` fields ← `ClaudeSettings.securityBlock*` ← Settings ▸ Claude Code ▸ Security; all default true = the original hard lock. Detection (`classify()`) runs UNCONDITIONALLY — a toggle only downgrades the OUTCOME `DENY`→`ASK` (for every caller, MCP/Skills included), never to ALLOW, so a disabled rule is still a card every time. `reason()` always names the Settings path. `AGENT_TOOLS` had gone stale as the CLI grew its own orchestration surface (`Task*`, `Cron*`, worktrees, `Agent`, `SendMessage`, MCP-resource tools…), so those FIRST-PARTY calls fell into the untrusted branch and were hard-DENIED like a blocked MCP server; rebuilt from the vendored SDK's `ToolInputSchemas`, with `Skill`/`mcp__*` still deliberately excluded. NB FOREIGN denies regardless of caller trust by design, so the allowlist fix only changes CREDENTIAL/DANGEROUS_COMMAND outcomes. **4.3.3 = model-picker autodetect + Opus pinned as default.** The picker was ALREADY autodetected from the `initialize` catalog, but it labelled entries with the binary's `displayName`, which omits the version ("Opus (1M context)", "Sonnet") — so Opus 4.8 vs Opus 5 was indistinguishable. The version lives in `description` ("Opus 5 with 1M context · …"), so `JcefState.modelDisplayLabel` now prefers that description head (→ `displayName` → `deriveModelLabel(id)`), and BOTH selectors (composer pill/menu + the Settings combo renderer) share it so they can't disagree. The binary lists a floating `default` alias AND the concrete `opus[1m]` it resolves to — the same model twice, the alias with no version — so `default` is filtered out of both lists (`ClaudeSession.RECOMMENDED_ALIAS`) and `DEFAULT_MODEL` is now the CONCRETE `opus[1m]` (was `"default"`), pinning Opus even if the binary re-points its recommendation. `ClaudeSession.preferredDefault(models)` is the graceful fallback (pin → binary's recommended alias → first listed), so we never select a model the binary doesn't offer; a legacy persisted `"default"` migrates on display (`reset()`) and via `changeModel`. Also killed a hardcoded `"Default · Opus 4.8"` pill literal that had gone stale the moment the recommended tier became Opus 5 — no version is baked in anywhere now. Re-baselined to `claude` 2.1.220 / SDK 0.3.220 (`checkDrift` green, protocol surface unchanged). **4.3.2 (re-cut) = command code block + syntax highlighting + two SensitiveGuard false-triggers.** The executed command renders as its own copyable code block in `.tool-cmd` — a SIBLING of `.tool-out`, so it's visible WITHOUT expanding the card (only the output stays behind the collapse toggle) — the header shows just the tool name (no raw-command churro), and the card gets a `cmd-tool` left accent. Detection is by input SHAPE, not tool name (`SensitiveGuard.commandText`/`isCommandCall` → `TranscriptEntry.commandText` → `JcefBridge` `command` field), so Bash, PowerShell and any MCP exec tool are covered by one rule that can't drift from the security rules it shares. Diffs and Read/Write/Edit output are syntax-highlighted from the file extension (`CC.languageForPath` → ~35 langs in the vendored hljs bundle; hljs autodetection as fallback), layered under the existing add/remove diff colouring. The two security fixes were REAL false-triggers found live: `isUnc()` flagged ANY `//`-prefixed string — including an ordinary `// comment` line inside an `Edit`'s `old_string` (`pathCandidates` walks every string leaf) — as a UNC share, i.e. FOREIGN, which hard-DENIES regardless of caller trust, so editing a commented line could be silently refused with no override; fixed by requiring the post-`//` host segment to be non-blank and whitespace-free. And `substituteAssignments` passed a shell-assigned value straight to `String.replace(Regex, String)`, which treats it as a REPLACEMENT TEMPLATE — a value containing `$`/`${…}` threw an uncaught `IllegalArgumentException: Illegal group reference` (confirmed via idea.log stack trace), crashing `verdict()` and leaving that `can_use_tool` unanswered; fixed with `Matcher.quoteReplacement`. **4.3.2 = WSL `/mnt/c` fix.** WSL2 surfaces the Windows `C:` drive over 9p (in `RemoteMounts.REMOTE_FS_TYPES`), so `detect()` put `/mnt/c` in `remoteRoots` and the startup gate (`RemoteMounts.isRemote`) refused to launch on a normal `C:\` project (and the same `remoteRoots` fed `SensitiveGuard`'s foreign rule). Fixed two layers: `detect()` drops all `/mnt/*` from `remoteRoots` under WSL (governed by the dedicated `/mnt/c` rule), and `isRemote` exempts `/mnt/c` before the fstype checks. **4.3.1 = deterministic sensitive-data lock (`permission/SensitiveGuard`, `session/RemoteMounts`) + jump-to-code + the chat-focus fix + live VFS refresh.** The sensitive-data lock intercepts every `can_use_tool` in `PermissionBroker.handle` before any auto-approval (so it holds in bypass/acceptEdits): credential/key globs (structural, cross-OS incl. WSL) + dangerous-command regexes (after path canonicalisation + shell de-obfuscation) + foreign territory (other user's home, network/UNC mount, non-`/mnt/c` WSL drive); agent tools ASK, MCP/Skills DENY, foreign DENY-for-all, no opt-out; project root exempt; won't start on a remote-mounted project. Validated live (native Read of `~/.claude/.credentials.json` → card in bypass; MCP → denied). Jump-to-code links in the transcript: a file tool card names its file PROJECT-RELATIVE and links it (`ClaudeSession.toolFilePath` → `TranscriptEntry.filePath` → `renderToolLabel`), and paths/dirs/symbols in model text are linked only after the host CONFIRMS them (`ui/LinkResolver.kt`: file index → Go-to-Symbol EP → bounded on-disk scan for excluded dirs like `build/`; unambiguous matches only, so no dead/misleading links). Security: `LinkResolver.isOpenable` (project OR $HOME, canonical, symlink-safe) gates opening — the WRITE gate (`DiffPresenter.isWithinRoot` in `PermissionBroker`/`FileRollback`) stays project-only. **The focus bug** that made a new tab unusable was NOT the JCEF bridge (three wrong hypotheses before the log settled it): the tab never declared `Content.preferredFocusedComponent` (and it must point at `cefBrowser.uiComponent` — `JBCefBrowser.getComponent()` is a non-focusable wrapper), and a raw `requestFocusInWindow()` is REFUSED while `IdeFocusManager` settles focus (measured: denied 34×). Fix = `setSelectedContent(content, requestFocus = true)` (the ContentManager transfers focus as part of the selection, the same path a manual tab switch takes) + telling CEF it has focus in `JcefHost.markWebReady()` — i.e. once the page EXISTS, since a freshly loaded page starts with its focus flag cleared and paints no caret. **VFS refresh is now per-write, not per-turn** (`ClaudeSession` ToolResult → `DiffLifecycleManager.refreshTouched()` for the exact paths + `refreshProjectTree()` when `mayHaveWrittenUnknownFiles(tool)` — Bash or a mutating MCP tool); `refreshTouched` also refreshes the PARENT dir, because refreshing a file the VFS has never heard of is a no-op and a newly CREATED file stayed invisible. NB `PluginId.getId(…)` is banned: `PluginId` is a Kotlin class since 2025.2, so it binds to `PluginId.Companion` and dies with `NoSuchFieldError` on any IDE below 252 — use `util/InstalledPlugins.kt` (id from the descriptor). **4.2.0 was a protocol-upgrade + dashboard release** — re-baselined to `claude` 2.1.204 / SDK 0.3.204: models `system/background_tasks_changed` (a **level** signal — the binary re-sends the FULL live background-task set on every membership change; tracked in `TaskTracker.backgroundTasks` with REPLACE semantics, kept **deliberately uncorrelated** with the edge-derived `subagentTasks` because the SDK leaves their relative ordering unspecified, and reset per-process in `clear()`) and surfaces it as a **"Background tasks"** dashboard card with Stop (`JcefSessionData.backgroundTasksJson` + `app-session.js buildBackgroundTasksCard`) — unlike the edge-derived Subagents list it can never wedge a stale "running" indicator; also models `system/control_request_progress` (progress for a host-originated control request, currently `side_question`/`/btw`: an `api_retry` status carries the same counters as `system/api_retry` and is surfaced the same way, `started` goes to debug). Triages the thin-client host→binary control requests the plugin knowingly never sends — `list_models` (the model catalog comes from the `initialize` reply), `get_plan`, `get_workspace_diff` — into `ProtocolSurface.KNOWN_SUBTYPES`. `./gradlew checkDrift` green at the new baseline. **4.1.0 adds editable diff review for edits:** when Claude asks to Edit/Write/MultiEdit, the plugin auto-opens an **editable** diff in the IDE editor (Current | Proposed, proposed side via `DiffContentFactory.createEditable`) on the permission request — not just in acceptEdits/bypass; the user can **tweak the proposed content** before accepting, **Accept writes their edited version** (`HunkSelection.encodeInput` re-encodes the tool input; fail-safe to the original proposal when unchanged/read-only), the captured snapshot is repointed at the effective input so the transcript inline diff + "View diff" show the **real** written change, and the diff closes on accept/reject/stop/interrupt (`DiffPresenter.openReviewDiff` + `DiffLifecycleManager` review-diff registry + `EditSnapshotStore.updateInput`). **4.0.5** replaced the permission card's per-hunk checkboxes with a **read-only colour diff** (per-line partial accept produced incoherent/broken edits; edits are now atomic — accept/reject the whole change). **4.0.4 (branch `bugfix/various-fixes`) is a broad bug-fix + UX pass:** the **interrupt** now actually stops the turn (correlated control request clears `turnActive`; transient "Interrupting…" on the Stop button via a `session.interrupting` flag; queue + pending permission cards flushed) instead of looping the "Interrupting…" notice forever; **first-open dead chat** is self-healed (the web app retries `ready` until `window.__ccSend` exists, and `JcefHost` reloads via `loadHTML` if the page doesn't come alive — kills the "reopen the tab" workaround); **user prompts render verbatim** (`buildUser()` is `kind:'text'`, never Markdown); the code-block **Copy** button works (a delegated `document` handler replaced the listener lost on `innerHTML` serialization); duplicate/out-of-order **"Thought process"** fixed in `TranscriptReconciler` (a `settledThinking` pointer finalize-replaces the streamed entry); **menu flicker/de-selection during streaming** fixed (incremental `renderState`, open menu rebuilt only when its selection changed; `JcefChatPanel.onAdded` no longer forces a full structural re-serialization for tail appends — was O(N²)); single ✓ in prompt menus; Esc on the find bar no longer also interrupts; **"Always allow"** resolves the exact card (carries the `requestId`, not first-by-tool-name) and a **zero-hunk accept is a deny**; permission re-push reconciles by `card.id` (no wiped elicitation/question/hunk input); the session **dashboard** lays out (`.dash-inner` grid, hides `#conversation` while open) without covering the composer; **clipboard paste runs off-EDT** with a deadline (no IDE freeze on a hung Wayland clipboard); the **find bar** scrolls to the active hit + Enter/Shift+Enter navigation (`i / n`); **adaptive thinking is on by default** (`ClaudeSettings.thinkingTokens = THINKING_ON`); faster Vibe Mode rainbow; **responsive** composer (pills wrap) / find / chips + truncated tab titles (full title in tooltip). Latent fixes: a `starting` guard + generation re-checks prevent a double `claude` spawn / mid-launch orphan, `dispose()` bumps the generation (no spurious "exited unexpectedly"), a malformed `can_use_tool` can't throw+hang the turn (replies error), and `ClaudeToolWindowFactory` resolves its tool window per-project (no shared-state cross-project bug). **Protocol re-baselined to `claude` 2.1.193 / SDK 0.3.193** — models `system/informational`·`model_refusal_no_fallback`·`worker_shutting_down`; `./gradlew checkDrift` green. **4.0.3 fixed composer clipboard paste on native-Wayland IDEs** — under `sun.awt.wl.WLToolkit` the embedded CEF browser's web clipboard is isolated from the system clipboard, so the composer's `paste` event never reached the host. `JcefState.metaJson` now emits a `hostClipboard` flag (true under the Wayland toolkit) and `app-composer.js` routes `Ctrl+V` straight to the host, which reads the real clipboard via `wl-paste`/`xclip` (the path the Attach→Image button already used). 4.0.2 had added that host-side `wl-paste`/`xclip` *read* fallback (`EditorContextProvider.clipboardText`/`clipboardHasText`, guarded by the pure `preferredTextType`) but it was never reached — the bug was the trigger, not the read (AWT/`CopyPasteManager` *reads* are broken on native Wayland; *writes* work). **4.0.1 is a protocol-upgrade release** — re-baselined to `claude` 2.1.170 / SDK 0.3.170: models the new `system/model_refusal_fallback` message (primary model refuses → turn retried on a fallback model; surfaced as a transcript notice) and triages the new `get_usage`/`register_repo_root`/`reload_skills` host→binary control requests into `ProtocolSurface.KNOWN_SUBTYPES`, so `./gradlew checkDrift` is green again. **4.0.0 rebuilds the entire chat UI on JCEF** (embedded Chromium web view — modern streaming transcript, web composer, native permission/question/elicitation cards, and a session dashboard; see "## JCEF UI (4.0.0)" above), and **deletes the old Swing chat UI** (`ChatPanel`/`TranscriptView`/`ChatMessageViews`/`MarkdownRenderer` + the tray/strip panels) and its tests. Earlier milestones (2.0.1 released on Marketplace; 2.1.0 unpublished — Marketplace blocked it on `findEnabledPlugin` internal API; 2.2.0 unblocked publication; 2.2.2 = full test pyramid; 3.2.1 = DeepSeek provider; **3.3.0 = full binary→host protocol surface mapped into the UI**: native MCP `elicitation` cards + correct `request_user_dialog` handling, predicted-next-prompt chip, live reasoning-token estimate, evolving hook-execution rows, memory-recall row, tool-use-summary/file-upload notices, plus the on-demand `./gradlew checkDrift` protocol drift detector). **3.0.0 nativizes the whole Agent SDK protocol surface** (all `system/*`+stream events, all host→binary control requests wired to GUI), with a redesigned composer, attachments + image drag&drop/paste, subagent strip, advanced launch options, plan mode, session rename/fork/delete, native hooks, and account/diagnostics dialogs — after a god-object decomposition and a final hardening pass. MVP + GUI complete and building clean.
+Package `dev.lain.claudejb`, plugin id `dev.lain.claude-code-for-jetbrains`, name **"Claude Code Native"**, version **5.0.1**, compatibility **251 → latest EAP/RC** (compiled against IC 2025.2; floor lowered from 252 in 4.3.1 — 251 is as far back as the API reaches with ZERO deprecations: `FileChooserDescriptorFactory.multiFiles()/singleDir()` in `FilePickerHelper` does not exist on 242/243, and its pre-251 equivalent is deprecated on current IDEs. Verified offline against locally-extracted IDEs via `-PlocalIdePath=[,…]`, which now takes a comma-separated list). **5.0.1 = the vaulted login survives a reboot.** The credential WAS persisting — `SecretStore` → IDE PasswordSafe → the OS store (verified on this Fedora box: `secret-tool search service "IntelliJ Platform Claude Code — CLAUDE_CREDENTIALS_JSON"` returns the blob from KWallet through the Secret Service; Keychain on macOS, Credential Manager on Windows, the same `PasswordSafe.instance` API for all three) — what expired was the ACCESS TOKEN inside it: `auth login` issues one good for ~10 h, so any restart the next day found a perfect credential that authenticated nothing, `hasUsableToken()` said false, and false meant signed out. The blob always carried a **refresh token good for weeks** (`refreshTokenExpiresAt`, ~30 d) that nothing was allowed to spend, since spending it means the binary rewriting `~/.claude/.credentials.json` — the file the vault exists to remove. The way out is in the binary itself and is a first-class path, not a trick: with `CLAUDE_CODE_OAUTH_REFRESH_TOKEN` + `CLAUDE_CODE_OAUTH_SCOPES` set, `claude auth login` takes a dedicated non-interactive branch (`tengu_login_from_refresh_token`, `expiresIn` 1 y requested, `POST platform.claude.com/v1/oauth/token`, `client_id 9d1c250a-…`) — no browser, no TTY, no user — mints a credential into its own store and exits 0; the SCOPES ride alongside it (the binary carries an explicit "required when using CLAUDE_CODE_OAUTH_REFRESH_TOKEN" refusal, and the grant cannot be restated without them). Verified live on 2.1.223 that the branch is genuinely non-interactive: a deliberately invalid refresh token fails on the HTTP round-trip and exits 1 — no browser, no TTY wait. So `AuthCli.loginFromRefreshToken` (60 s timeout, the binary's own HTTP timeout is 30 s) + `CredentialsVault.canRenew`/`needsRenewal`/`renew` do exactly what every other credential path here does: capture the account while `~/.claude.json` is freshest, `harvest()` the credential off the disk, done. **The plugin still holds no OAuth client, calls no token endpoint and never writes that file back** — the invariant is intact, only its cost is gone. Wiring: `hasCredential` counts an expired-but-renewable blob as an identity (it runs on the EDT, so it must NOT renew), the renewal itself is `ClaudeSession.renewVaultedCredential` inside `launch()` (pooled) BEFORE the launch env is built and never while `login.inProgress` (both write the same file), the refresh token ROTATES at every renewal so ordinary use extends it indefinitely, and a failure arms a 5-min cooldown inside `canRenew()` because the boot watcher polls every 3 s — without it a flaky network becomes a process spawn per poll. On a failed renewal the ttl cache `ownLoginCheckedAt` is dropped and `hasCredential` re-asked, since the renewal can sign the BINARY in even when we fail to take custody. **This was reported on Linux AND Windows and it is ONE bug, not two**: the binary's default credential store is the `plaintext` provider (`~/.claude/.credentials.json`) on every platform — the keychain prefetch is stubbed and the Windows-Credential-Manager flag does not replace it — so the vault path, and therefore the expiry, is identical everywhere. No platform-specific code; the only Windows-specific care is that the refresh env strips `CLAUDE_CODE_OAUTH_TOKEN` case-INsensitively, since a hand-written `Claude_Code_Oauth_Token` in Settings would otherwise survive and be the expired token the renewal is replacing. **5.0.0 = the standards-compliance major.** The repository was put through the standards catalogue domain by domain, and the major reflects that the *code* changed, not just the docs. It did NOT stay purely that: it also ships the **plan-limits panel** (`get_usage`, a control request known since 4.0.1 and never sent — all rate-limit windows plus the extra-credit balance, as dashboard bars and composer dots, blue <65% / amber <85% / red above, announced once per threshold per window) and a run of user-facing fixes, the largest being a **tab-killing NPE this branch itself introduced**: `JcefChatPanel.pendingUntilReady` was declared BELOW the `init` block that uses it, and Kotlin runs property initializers and `init` blocks in declaration order — so it was null inside the constructor and NO chat could be opened or restored. `lastUsage`/`lastUsageAt` had the same defect and stayed silent (nullable/primitive read as null/0), which is why `InitOrderContractTest` now scans the sources: the compiler only flags a *direct* reference in an initializer, not one made through a function called from `init`. Also from that pass: a **boot screen** (the binary is launched BEFORE the tab is built, since `start()` only dispatches; FOUR states — running / starting / **binaryMissing** (the install-or-path onboarding card) / neither, that last being a launch that failed for another reason and MUST clear the screen); context and cost polled on ready, tab-open and both turn edges instead of waiting out a `javax.swing.Timer` whose initial delay equals its 60s interval (and the timer now retires at turn end — those numbers cannot move while idle); the CLI's `` wrapper stripped in `ProtocolParser.unwrapToolError` (verified in 2.1.222, which carries the same text unwrapped in a sibling field — rendering it verbatim put raw markup in a native GUI); failed tool cards auto-open once and wrap their error text (collapsed, the whole message was "the header is red"); `ToolSearch` + `AskUserQuestion`/`Mcp`/`FileRead`/`FileEdit`/`FileWrite` added to `SensitiveGuard.AGENT_TOOLS` — **that list is only ever appended to**, it is a trust allowlist and not an inventory, and `ToolSearch` was the load-bearing gap (it loads every deferred tool's schema, so on a session that defers them the call unlocking all the others was landing in the untrusted branch); and markdown links whose href is a path now open (`LinkResolver.isFilePathHref`, with a two-or-more-character scheme test so a Windows drive stays a path) through the same `isOpenable` gate as `jb://`. (1) **Dependency scope corrected** — `@anthropic-ai/claude-agent-sdk` sat in `dependencies` while it is protocol reference only, producing 7 permanent npm-audit findings (3 high) against code no user receives; moved to `devDependencies` (`npm audit --omit=dev` → 0), `checkDrift` verified green across the move (it reads the SDK from `node_modules` and runs `npm update`; only `--omit=dev` would break it) and re-baselined to `claude` 2.1.222 / SDK 0.3.222. `package.json` also declared `"license": "ISC"` on a GPL-3.0-only repo and was missing `"private": true` — i.e. publishable to npm under the wrong licence. (2) **`LoginCoordinator` extracted** from `ClaudeSession` (1965 → 1826 lines) — the OAuth subsystem and its three state fields; mechanical, no behaviour change, 677 tests green across it. The other two extractions the plan proposed (`SessionRestorer`, `RewindCoordinator`) were **deliberately not done**: `restore` is 23 lines that write six pieces of session state, and rewind is one of six identically-shaped `controlClient.query` delegates — extracting either buys indirection, not cohesion. (3) **Accessibility** — WCAG 4.1.3 live region (`#a11y-status`, declared in the static shell so the first write is announced) + `CC.announce`, and a `:focus-visible` baseline with a `forced-colors` fallback, pinned by 10 frontend tests; the EU Accessibility Act has applied since 28-jun-2025. (4) **Attribution ships inside the artifact** (`THIRD-PARTY-NOTICES.md`, `LICENSE`, `LICENSES/*` under `META-INF/`) — a permissive licence's notice obligation binds on *redistribution*, and the plugin redistributes marked/DOMPurify/highlight.js. (5) **Governance**: commitlint + a versioned `.githooks/commit-msg` that degrades to advisory if the toolchain fails (so it never becomes a reason to reach for `--no-verify`), `.gitattributes`, and three ADRs — [0001](docs/adr/0001-release-process.md) release process (GitFlow and GPG-on-YubiKey as *recorded deviations*, tag immutability as a **correction**: `v4.3.2` and `v4.4.1` were each force-re-cut three times, which is exactly what a signature is supposed to prevent; plus the generated-CHANGELOG deferral with a one-command exit test), [0002](docs/adr/0002-threat-model.md) threat model (trust model + STRIDE over binary/MCP/model-content; prompt injection is **assumed to succeed**, not detected), [0003](docs/adr/0003-i18n-deferred.md) i18n deferred with its triggers. **4.4.1 = `/login` terminal launch fixed (REAL regression, silent).** Every platform API `TerminalLauncher` reflected on was missing at runtime: the Reworked path looked up `com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager`, which is NOT in the shipped IDE at all (scanned every jar of IU-262.8665.337), and the Classic path used `TerminalToolWindowManager.createShellWidget(…)`/`.createLocalShellWidget(…)`, present on 251/252 but REMOVED by 262. Each lookup returns false rather than throwing → totally silent, nothing in idea.log, `/login` always landed on the "run it yourself" notice. Fix: `createNewSession(workingDirectory, tabName, shellCommand, requestFocus, deferSessionStartUntilUiShown)`, verified by hand on 251+252+262, with the login passed as **argv** (`TerminalLauncher.loginArgv`) not a shell string — killing the quoting hazard (Windows `&` prefix, spaces) and the send-into-a-shell race at once. **Why CI missed it:** the plugin compiles/tests against IC-2025.2, where the removed factories still exist — the break only manifests at 262+, so `TerminalApiContractTest` pins the replacement against the build classpath and `verifyPlugin`'s range run is the complementary half. Also wired `ClaudeLoginFlow` (pty4j) in as a REAL fallback — it was unreachable code, since `startLogin()` called the terminal unconditionally — so order is now terminal → native PTY → manual notice; and fixed a latent bug there: pty4j REPLACES the child env wholesale (unlike `ClaudeProcess`, which inherits via `withParentEnvironmentType(CONSOLE)`), so `System.getenv()` must be merged in or the spawned binary loses `PATH`/`HOME`. **4.4.0 = per-rule security toggles + `AGENT_TOOLS` allowlist fix.** Each `SensitiveGuard` rule (CREDENTIAL, DANGEROUS_COMMAND, and FOREIGN split into its three sub-rules via `ForeignReason`) is independently switchable via five `Policy.enforce*` fields ← `ClaudeSettings.securityBlock*` ← Settings ▸ Claude Code ▸ Security; all default true = the original hard lock. Detection (`classify()`) runs UNCONDITIONALLY — a toggle only downgrades the OUTCOME `DENY`→`ASK` (for every caller, MCP/Skills included), never to ALLOW, so a disabled rule is still a card every time. `reason()` always names the Settings path. `AGENT_TOOLS` had gone stale as the CLI grew its own orchestration surface (`Task*`, `Cron*`, worktrees, `Agent`, `SendMessage`, MCP-resource tools…), so those FIRST-PARTY calls fell into the untrusted branch and were hard-DENIED like a blocked MCP server; rebuilt from the vendored SDK's `ToolInputSchemas`, with `Skill`/`mcp__*` still deliberately excluded. NB FOREIGN denies regardless of caller trust by design, so the allowlist fix only changes CREDENTIAL/DANGEROUS_COMMAND outcomes. **4.3.3 = model-picker autodetect + Opus pinned as default.** The picker was ALREADY autodetected from the `initialize` catalog, but it labelled entries with the binary's `displayName`, which omits the version ("Opus (1M context)", "Sonnet") — so Opus 4.8 vs Opus 5 was indistinguishable. The version lives in `description` ("Opus 5 with 1M context · …"), so `JcefState.modelDisplayLabel` now prefers that description head (→ `displayName` → `deriveModelLabel(id)`), and BOTH selectors (composer pill/menu + the Settings combo renderer) share it so they can't disagree. The binary lists a floating `default` alias AND the concrete `opus[1m]` it resolves to — the same model twice, the alias with no version — so `default` is filtered out of both lists (`ClaudeSession.RECOMMENDED_ALIAS`) and `DEFAULT_MODEL` is now the CONCRETE `opus[1m]` (was `"default"`), pinning Opus even if the binary re-points its recommendation. `ClaudeSession.preferredDefault(models)` is the graceful fallback (pin → binary's recommended alias → first listed), so we never select a model the binary doesn't offer; a legacy persisted `"default"` migrates on display (`reset()`) and via `changeModel`. Also killed a hardcoded `"Default · Opus 4.8"` pill literal that had gone stale the moment the recommended tier became Opus 5 — no version is baked in anywhere now. Re-baselined to `claude` 2.1.220 / SDK 0.3.220 (`checkDrift` green, protocol surface unchanged). **4.3.2 (re-cut) = command code block + syntax highlighting + two SensitiveGuard false-triggers.** The executed command renders as its own copyable code block in `.tool-cmd` — a SIBLING of `.tool-out`, so it's visible WITHOUT expanding the card (only the output stays behind the collapse toggle) — the header shows just the tool name (no raw-command churro), and the card gets a `cmd-tool` left accent. Detection is by input SHAPE, not tool name (`SensitiveGuard.commandText`/`isCommandCall` → `TranscriptEntry.commandText` → `JcefBridge` `command` field), so Bash, PowerShell and any MCP exec tool are covered by one rule that can't drift from the security rules it shares. Diffs and Read/Write/Edit output are syntax-highlighted from the file extension (`CC.languageForPath` → ~35 langs in the vendored hljs bundle; hljs autodetection as fallback), layered under the existing add/remove diff colouring. The two security fixes were REAL false-triggers found live: `isUnc()` flagged ANY `//`-prefixed string — including an ordinary `// comment` line inside an `Edit`'s `old_string` (`pathCandidates` walks every string leaf) — as a UNC share, i.e. FOREIGN, which hard-DENIES regardless of caller trust, so editing a commented line could be silently refused with no override; fixed by requiring the post-`//` host segment to be non-blank and whitespace-free. And `substituteAssignments` passed a shell-assigned value straight to `String.replace(Regex, String)`, which treats it as a REPLACEMENT TEMPLATE — a value containing `$`/`${…}` threw an uncaught `IllegalArgumentException: Illegal group reference` (confirmed via idea.log stack trace), crashing `verdict()` and leaving that `can_use_tool` unanswered; fixed with `Matcher.quoteReplacement`. **4.3.2 = WSL `/mnt/c` fix.** WSL2 surfaces the Windows `C:` drive over 9p (in `RemoteMounts.REMOTE_FS_TYPES`), so `detect()` put `/mnt/c` in `remoteRoots` and the startup gate (`RemoteMounts.isRemote`) refused to launch on a normal `C:\` project (and the same `remoteRoots` fed `SensitiveGuard`'s foreign rule). Fixed two layers: `detect()` drops all `/mnt/*` from `remoteRoots` under WSL (governed by the dedicated `/mnt/c` rule), and `isRemote` exempts `/mnt/c` before the fstype checks. **4.3.1 = deterministic sensitive-data lock (`permission/SensitiveGuard`, `session/RemoteMounts`) + jump-to-code + the chat-focus fix + live VFS refresh.** The sensitive-data lock intercepts every `can_use_tool` in `PermissionBroker.handle` before any auto-approval (so it holds in bypass/acceptEdits): credential/key globs (structural, cross-OS incl. WSL) + dangerous-command regexes (after path canonicalisation + shell de-obfuscation) + foreign territory (other user's home, network/UNC mount, non-`/mnt/c` WSL drive); agent tools ASK, MCP/Skills DENY, foreign DENY-for-all, no opt-out; project root exempt; won't start on a remote-mounted project. Validated live (native Read of `~/.claude/.credentials.json` → card in bypass; MCP → denied). Jump-to-code links in the transcript: a file tool card names its file PROJECT-RELATIVE and links it (`ClaudeSession.toolFilePath` → `TranscriptEntry.filePath` → `renderToolLabel`), and paths/dirs/symbols in model text are linked only after the host CONFIRMS them (`ui/LinkResolver.kt`: file index → Go-to-Symbol EP → bounded on-disk scan for excluded dirs like `build/`; unambiguous matches only, so no dead/misleading links). Security: `LinkResolver.isOpenable` (project OR $HOME, canonical, symlink-safe) gates opening — the WRITE gate (`DiffPresenter.isWithinRoot` in `PermissionBroker`/`FileRollback`) stays project-only. **The focus bug** that made a new tab unusable was NOT the JCEF bridge (three wrong hypotheses before the log settled it): the tab never declared `Content.preferredFocusedComponent` (and it must point at `cefBrowser.uiComponent` — `JBCefBrowser.getComponent()` is a non-focusable wrapper), and a raw `requestFocusInWindow()` is REFUSED while `IdeFocusManager` settles focus (measured: denied 34×). Fix = `setSelectedContent(content, requestFocus = true)` (the ContentManager transfers focus as part of the selection, the same path a manual tab switch takes) + telling CEF it has focus in `JcefHost.markWebReady()` — i.e. once the page EXISTS, since a freshly loaded page starts with its focus flag cleared and paints no caret. **VFS refresh is now per-write, not per-turn** (`ClaudeSession` ToolResult → `DiffLifecycleManager.refreshTouched()` for the exact paths + `refreshProjectTree()` when `mayHaveWrittenUnknownFiles(tool)` — Bash or a mutating MCP tool); `refreshTouched` also refreshes the PARENT dir, because refreshing a file the VFS has never heard of is a no-op and a newly CREATED file stayed invisible. NB `PluginId.getId(…)` is banned: `PluginId` is a Kotlin class since 2025.2, so it binds to `PluginId.Companion` and dies with `NoSuchFieldError` on any IDE below 252 — use `util/InstalledPlugins.kt` (id from the descriptor). **4.2.0 was a protocol-upgrade + dashboard release** — re-baselined to `claude` 2.1.204 / SDK 0.3.204: models `system/background_tasks_changed` (a **level** signal — the binary re-sends the FULL live background-task set on every membership change; tracked in `TaskTracker.backgroundTasks` with REPLACE semantics, kept **deliberately uncorrelated** with the edge-derived `subagentTasks` because the SDK leaves their relative ordering unspecified, and reset per-process in `clear()`) and surfaces it as a **"Background tasks"** dashboard card with Stop (`JcefSessionData.backgroundTasksJson` + `app-session.js buildBackgroundTasksCard`) — unlike the edge-derived Subagents list it can never wedge a stale "running" indicator; also models `system/control_request_progress` (progress for a host-originated control request, currently `side_question`/`/btw`: an `api_retry` status carries the same counters as `system/api_retry` and is surfaced the same way, `started` goes to debug). Triages the thin-client host→binary control requests the plugin knowingly never sends — `list_models` (the model catalog comes from the `initialize` reply), `get_plan`, `get_workspace_diff` — into `ProtocolSurface.KNOWN_SUBTYPES`. `./gradlew checkDrift` green at the new baseline. **4.1.0 adds editable diff review for edits:** when Claude asks to Edit/Write/MultiEdit, the plugin auto-opens an **editable** diff in the IDE editor (Current | Proposed, proposed side via `DiffContentFactory.createEditable`) on the permission request — not just in acceptEdits/bypass; the user can **tweak the proposed content** before accepting, **Accept writes their edited version** (`HunkSelection.encodeInput` re-encodes the tool input; fail-safe to the original proposal when unchanged/read-only), the captured snapshot is repointed at the effective input so the transcript inline diff + "View diff" show the **real** written change, and the diff closes on accept/reject/stop/interrupt (`DiffPresenter.openReviewDiff` + `DiffLifecycleManager` review-diff registry + `EditSnapshotStore.updateInput`). **4.0.5** replaced the permission card's per-hunk checkboxes with a **read-only colour diff** (per-line partial accept produced incoherent/broken edits; edits are now atomic — accept/reject the whole change). **4.0.4 (branch `bugfix/various-fixes`) is a broad bug-fix + UX pass:** the **interrupt** now actually stops the turn (correlated control request clears `turnActive`; transient "Interrupting…" on the Stop button via a `session.interrupting` flag; queue + pending permission cards flushed) instead of looping the "Interrupting…" notice forever; **first-open dead chat** is self-healed (the web app retries `ready` until `window.__ccSend` exists, and `JcefHost` reloads via `loadHTML` if the page doesn't come alive — kills the "reopen the tab" workaround); **user prompts render verbatim** (`buildUser()` is `kind:'text'`, never Markdown); the code-block **Copy** button works (a delegated `document` handler replaced the listener lost on `innerHTML` serialization); duplicate/out-of-order **"Thought process"** fixed in `TranscriptReconciler` (a `settledThinking` pointer finalize-replaces the streamed entry); **menu flicker/de-selection during streaming** fixed (incremental `renderState`, open menu rebuilt only when its selection changed; `JcefChatPanel.onAdded` no longer forces a full structural re-serialization for tail appends — was O(N²)); single ✓ in prompt menus; Esc on the find bar no longer also interrupts; **"Always allow"** resolves the exact card (carries the `requestId`, not first-by-tool-name) and a **zero-hunk accept is a deny**; permission re-push reconciles by `card.id` (no wiped elicitation/question/hunk input); the session **dashboard** lays out (`.dash-inner` grid, hides `#conversation` while open) without covering the composer; **clipboard paste runs off-EDT** with a deadline (no IDE freeze on a hung Wayland clipboard); the **find bar** scrolls to the active hit + Enter/Shift+Enter navigation (`i / n`); **adaptive thinking is on by default** (`ClaudeSettings.thinkingTokens = THINKING_ON`); faster Vibe Mode rainbow; **responsive** composer (pills wrap) / find / chips + truncated tab titles (full title in tooltip). Latent fixes: a `starting` guard + generation re-checks prevent a double `claude` spawn / mid-launch orphan, `dispose()` bumps the generation (no spurious "exited unexpectedly"), a malformed `can_use_tool` can't throw+hang the turn (replies error), and `ClaudeToolWindowFactory` resolves its tool window per-project (no shared-state cross-project bug). **Protocol re-baselined to `claude` 2.1.193 / SDK 0.3.193** — models `system/informational`·`model_refusal_no_fallback`·`worker_shutting_down`; `./gradlew checkDrift` green. **4.0.3 fixed composer clipboard paste on native-Wayland IDEs** — under `sun.awt.wl.WLToolkit` the embedded CEF browser's web clipboard is isolated from the system clipboard, so the composer's `paste` event never reached the host. `JcefState.metaJson` now emits a `hostClipboard` flag (true under the Wayland toolkit) and `app-composer.js` routes `Ctrl+V` straight to the host, which reads the real clipboard via `wl-paste`/`xclip` (the path the Attach→Image button already used). 4.0.2 had added that host-side `wl-paste`/`xclip` *read* fallback (`EditorContextProvider.clipboardText`/`clipboardHasText`, guarded by the pure `preferredTextType`) but it was never reached — the bug was the trigger, not the read (AWT/`CopyPasteManager` *reads* are broken on native Wayland; *writes* work). **4.0.1 is a protocol-upgrade release** — re-baselined to `claude` 2.1.170 / SDK 0.3.170: models the new `system/model_refusal_fallback` message (primary model refuses → turn retried on a fallback model; surfaced as a transcript notice) and triages the new `get_usage`/`register_repo_root`/`reload_skills` host→binary control requests into `ProtocolSurface.KNOWN_SUBTYPES`, so `./gradlew checkDrift` is green again. **4.0.0 rebuilds the entire chat UI on JCEF** (embedded Chromium web view — modern streaming transcript, web composer, native permission/question/elicitation cards, and a session dashboard; see "## JCEF UI (4.0.0)" above), and **deletes the old Swing chat UI** (`ChatPanel`/`TranscriptView`/`ChatMessageViews`/`MarkdownRenderer` + the tray/strip panels) and its tests. Earlier milestones (2.0.1 released on Marketplace; 2.1.0 unpublished — Marketplace blocked it on `findEnabledPlugin` internal API; 2.2.0 unblocked publication; 2.2.2 = full test pyramid; 3.2.1 = DeepSeek provider; **3.3.0 = full binary→host protocol surface mapped into the UI**: native MCP `elicitation` cards + correct `request_user_dialog` handling, predicted-next-prompt chip, live reasoning-token estimate, evolving hook-execution rows, memory-recall row, tool-use-summary/file-upload notices, plus the on-demand `./gradlew checkDrift` protocol drift detector). **3.0.0 nativizes the whole Agent SDK protocol surface** (all `system/*`+stream events, all host→binary control requests wired to GUI), with a redesigned composer, attachments + image drag&drop/paste, subagent strip, advanced launch options, plan mode, session rename/fork/delete, native hooks, and account/diagnostics dialogs — after a god-object decomposition and a final hardening pass. MVP + GUI complete and building clean.
**4.0.0 post-rewrite UI/UX hardening (frontend-only — the Kotlin backend was untouched, validating the binary-direct architecture):** subagent activity nests inside its Agent/Task card with per-card collapse (was a CSS descendant-selector bug); **native rewind as the default rollback** — "Restore" asks Claude Code to `rewind_files` to that turn (client-tagged user-message `uuid` + `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING`, setting default-on), with a confirmed IDE-side per-file revert fallback (`ClaudeSession.requestRewindFiles`/`userMessageIdFor`); **clipboard paste on Wayland** read host-side (image via `wl-paste`/`xclip` resolved across common bin dirs, plus `text/uri-list` for copied image files; **text via AWT with a `wl-paste`/`xclip` fallback added in 4.0.2** for the native Wayland toolkit); tool-card states (loading/running fade sky-blue↔amber, done green, **error red** via `ToolState.ERROR`), colourised inline edit diffs, flat single-row composer control bar with the ported icon set, Ctrl+O reasoning toggle (collapsed by default), auto-follow toggle, 🌈 Vibe Mode (Nyan Cat + rainbow), diffs open without stealing keyboard focus, request cards capped at 50% height (scrollable body, actions always visible) with a Cancel on question cards, `/login` runs in the IDE terminal (browser auto-capture) and appears in the palette, "Explain with Claude" carries the Claude icon, and the ⚙ menu reuses the formatted JCEF dashboard. Fixes: a non-compiling tree (`object a ChatTheme` + a nested-comment KDoc), and session-cost + JetBrains-MCP reading the binary's `mcpServers` (camelCase) reply.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 6e80c401..79d56e6c 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,23 @@
+## v5.0.1 — 2026-08-10
+
+**You should stop having to sign in every morning.** Your login was being stored properly all along — in your
+OS credential store, through the IDE's own password safe (KWallet or GNOME Keyring on Linux, Keychain on
+macOS, Credential Manager on Windows). What was expiring was the token inside it: Claude issues one that lasts
+hours, so a restart the next day found a credential that had gone stale, and the plugin asked you to sign in
+again rather than renewing it.
+
+It renews it now. The longer-lived half of your credential — the part good for weeks, and refreshed every time
+it's used — is handed back to the `claude` binary, which mints a new token without a browser, without a
+terminal and without you. Nothing about how it's stored changes: the credential still lives encrypted in your
+OS store and never sits in plaintext on disk. In practice you'll now only be asked to sign in after a long
+idle period, or if Anthropic invalidates the session.
+
+**Which sign-in this is about:** the **subscription** one (Claude Pro or Max — the *Sign in* button and the
+account row in the dashboard). That is the credential that carries a token with an expiry date on it. If you
+authenticate with an **Anthropic API key** instead, nothing here changes for you and nothing here was broken
+for you: an API key does not expire and has nothing to renew, it is kept in the same OS-backed store, and it
+already survived restarts.
+
## v5.0.0 — 2026-08-05
**Nothing you use changes.** This is a major because the *project* changed, not the product: the whole
diff --git a/build.gradle.kts b/build.gradle.kts
index 610c3b6f..1aac9fae 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -28,7 +28,7 @@ plugins {
}
group = "dev.lain"
-version = "5.0.0"
+version = "5.0.1"
repositories {
mavenCentral()
diff --git a/src/main/kotlin/dev/lain/claudejb/process/AuthCli.kt b/src/main/kotlin/dev/lain/claudejb/process/AuthCli.kt
index bf51ce95..f46a2e32 100644
--- a/src/main/kotlin/dev/lain/claudejb/process/AuthCli.kt
+++ b/src/main/kotlin/dev/lain/claudejb/process/AuthCli.kt
@@ -95,8 +95,38 @@ object AuthCli {
fun logout(binary: File, env: Map): Boolean =
run(binary, env, "auth", "logout") != null
+ /**
+ * **Non-interactive** `claude auth login`, driven entirely by a refresh token in the environment — no
+ * browser, no TTY, no user.
+ *
+ * This is a first-class path in the binary, not a trick: given `CLAUDE_CODE_OAUTH_REFRESH_TOKEN` the
+ * command takes a dedicated branch (`tengu_login_from_refresh_token`), exchanges the token at
+ * `platform.claude.com/v1/oauth/token` and stores the result in its own credential store, then exits 0.
+ * `CLAUDE_CODE_OAUTH_SCOPES` accompanies it and is always sent: the binary carries an explicit refusal
+ * for the case where it is missing ("required when using CLAUDE_CODE_OAUTH_REFRESH_TOKEN", naming the
+ * space-separated scopes it wants), and the grant cannot be restated without it — so
+ * [dev.lain.claudejb.process.CredentialsVault.renew] will not attempt a renewal from a blob that carries
+ * no scopes. Verified against `claude` 2.1.223 that the branch is taken and is genuinely non-interactive:
+ * with a deliberately invalid refresh token it fails on the HTTP round-trip and exits 1 without opening
+ * a browser or waiting on a terminal.
+ *
+ * That is what makes the vaulted login survive a reboot: the access token lives hours, the refresh token
+ * lives weeks, and this is the plugin's way of spending the second to mint the first WITHOUT holding an
+ * OAuth client itself. Not "the host refreshes the token" — the binary does, exactly as it always has.
+ *
+ * Its own 30 s HTTP timeout sits under this one, hence the longer wait: a renewal killed at 15 s would be
+ * reported as a failed login when it was merely a slow network.
+ */
+ fun loginFromRefreshToken(binary: File, env: Map): Boolean =
+ run(binary, env, "auth", "login", timeoutMs = LOGIN_TIMEOUT_MS) != null
+
/** Runs the binary with [args] and the given env; null on spawn failure, timeout or non-zero exit. */
- private fun run(binary: File, env: Map, vararg args: String): String? {
+ private fun run(
+ binary: File,
+ env: Map,
+ vararg args: String,
+ timeoutMs: Int = TIMEOUT_MS,
+ ): String? {
val output = runCatching {
val cmd = GeneralCommandLine(listOf(binary.absolutePath) + args)
.withEnvironment(env)
@@ -104,11 +134,14 @@ object AuthCli {
// destroyOnTimeout: a binary that never answers must not outlive the question. Without it the
// timeout only stops us WAITING — the process and its stream readers stay alive, which surfaced as
// a leaked-thread failure attributed to whichever test ran next.
- CapturingProcessHandler(cmd).runProcess(TIMEOUT_MS, true)
+ CapturingProcessHandler(cmd).runProcess(timeoutMs, true)
}.getOrNull() ?: return null
if (output.isTimeout || output.exitCode != 0) return null
return output.stdout
}
private const val TIMEOUT_MS = 15_000
+
+ /** A renewal is a network round-trip with a 30 s timeout of its own; 15 s would cut it short. */
+ private const val LOGIN_TIMEOUT_MS = 60_000
}
diff --git a/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt b/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt
index 3b66bef3..11f13756 100644
--- a/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt
+++ b/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt
@@ -39,10 +39,16 @@ import java.io.File
* delete in the (now removed) session config dir followed symlinks into `~/.claude` and destroyed a user's
* conversations, skills and session history. Nothing else on their disk is ours to remove.
*
- * The cost is stated rather than hidden: only the binary can spend the refresh token, and it does that by
- * rewriting its own file. With no file it cannot, so when the access token expires the credential is simply
- * spent and the sign-in card comes back. A periodic sign-in is the price of never having a bearer token
- * sitting in a world-readable-by-the-user file.
+ * **Expiry is handled by renewal, not by asking the user again** ([renew]). The access token lives hours —
+ * measured at ~10 h on a fresh `auth login` — so with nothing but the token in the safe the identity died
+ * overnight and the sign-in card was back after every reboot: the credential persisted perfectly and simply
+ * expired. Only the binary can spend a refresh token, and that stays true here; the plugin does not hold an
+ * OAuth client, does not talk to the token endpoint and does not write the file back. It runs the binary's
+ * own non-interactive `auth login` with the vaulted refresh token in the environment
+ * ([AuthCli.loginFromRefreshToken]), lets it mint and store a fresh credential, and harvests that the same
+ * way it harvests any other login. The refresh token (weeks, and rotated at every renewal) becomes the thing
+ * that survives a restart, and the plaintext file exists only for the moment between the binary writing it
+ * and [harvest] taking it away.
*/
object CredentialsVault {
@@ -60,6 +66,9 @@ object CredentialsVault {
*/
private const val EXPIRY_MARGIN_MS = 10 * 60 * 1000L
+ /** How long a failed renewal stops us trying again — the boot watcher polls every few seconds. */
+ private const val RENEW_COOLDOWN_MS = 5 * 60 * 1000L
+
// The rest of the credential's env surface. Verified present in the shipped CLI's own env registry
// (`sdk.mjs`/`bridge.mjs` name them, and sdk.mjs lists the OAuth ones in its subprocess passthrough).
// `SecretStore.OAUTH_TOKEN` carries the access token itself.
@@ -171,14 +180,87 @@ object CredentialsVault {
}
/**
- * Whether the vault holds a subscription credential that can still authenticate a session.
+ * Whether the vault holds an access token that can authenticate a session **right now**.
*
- * An EXPIRED blob deliberately answers false: it cannot be refreshed without writing the file back, so
- * it is not an identity any more. Callers treat that as signed-out and show the card, which beats
- * launching a session that will fail its first turn.
+ * An expired blob answers false — but that is no longer the end of the identity: see [canRenew], which
+ * asks the second question ("can we mint a new one?"). Callers wanting "is there an identity at all"
+ * must consider both, or they will show a sign-in card to a user whose credential only needed renewing.
*/
fun hasUsableToken(): Boolean = usableToken() != null
+ /**
+ * Whether the vaulted blob can be turned back into a live access token without the user.
+ *
+ * Three conditions, all from the blob itself: a refresh token, the scopes it was issued with (the
+ * non-interactive path asks for them and the grant cannot be restated without them — see
+ * [AuthCli.loginFromRefreshToken]), and a
+ * `refreshTokenExpiresAt` that is still in the future. A blob with no expiry recorded is given the
+ * benefit of the doubt: the endpoint is the authority on that, and a wrong guess here costs one failed
+ * renewal, while refusing costs a sign-in the user did not need.
+ *
+ * Also false during the cooldown a failed renewal sets, so a caller that polls every few seconds cannot
+ * turn a transient network failure into a process spawn every few seconds.
+ */
+ fun canRenew(): Boolean {
+ if (System.currentTimeMillis() < renewBlockedUntil) return false
+ val oauth = oauthNode() ?: return false
+ if (oauth.string("refreshToken") == null) return false
+ if (oauth.strings("scopes").isNullOrEmpty()) return false
+ val expiresAt = oauth["refreshTokenExpiresAt"]?.jsonPrimitive?.longOrNull ?: return true
+ return expiresAt - System.currentTimeMillis() > EXPIRY_MARGIN_MS
+ }
+
+ /** An identity that exists but is not usable as it stands — exactly the case [renew] exists for. */
+ fun needsRenewal(): Boolean = usableToken() == null && canRenew()
+
+ /**
+ * Mints a fresh credential from the vaulted refresh token, by running the binary's own non-interactive
+ * `auth login` ([AuthCli.loginFromRefreshToken]) and taking custody of what it writes.
+ *
+ * BLOCKING — it spawns a process and makes a network call. Pooled thread only, and never while a
+ * [dev.lain.claudejb.session.LoginCoordinator] sign-in is in flight: both write the same file, and the
+ * caller owns that guard.
+ *
+ * The order after a successful login is the same one every other credential path here follows, for the
+ * same reason: [AccountProfile.capture] asks `~/.claude.json` WHO this is while the login is freshest,
+ * then [harvest] takes the credential off the disk. Reversed, the question can still be answered — but a
+ * renewal is also the moment the account object is rewritten, so capturing here keeps the dashboard's
+ * identity from ageing out with the token that carried it.
+ *
+ * A failure of any leg (login, harvest, or a harvested blob that still is not usable) arms a cooldown
+ * and answers false; the caller then falls back to whatever other identity exists, and ultimately to the
+ * sign-in card. Nothing is cleared: a transient failure must not destroy a refresh token that is still
+ * perfectly good for the next attempt.
+ *
+ * @param baseEnv the RAW settings env. Deliberately not the launch env — handing the binary the expired
+ * access token we are trying to replace is at best noise and at worst the thing it authenticates with.
+ */
+ fun renew(binary: File, baseEnv: Map): Boolean {
+ if (inertHere()) return false
+ val oauth = oauthNode() ?: return false
+ val refreshToken = oauth.string("refreshToken") ?: return false
+ val scopes = oauth.strings("scopes")?.takeIf { it.isNotEmpty() } ?: return false
+ // Case-insensitively: environment names are case-insensitive on Windows, so a hand-written
+ // `Claude_Code_Oauth_Token` in Settings would survive an exact-match removal and then be the very
+ // expired token the renewal is trying to replace.
+ val env = baseEnv.filterKeys { !it.equals(SecretStore.OAUTH_TOKEN, ignoreCase = true) } + mapOf(
+ ENV_REFRESH_TOKEN to refreshToken,
+ ENV_SCOPES to scopes.joinToString(" "),
+ )
+ val renewed = AuthCli.loginFromRefreshToken(binary, env) && run {
+ AccountProfile.capture()
+ harvest()
+ hasUsableToken()
+ }
+ if (!renewed) log.warn("could not renew the vaulted credential from its refresh token")
+ renewBlockedUntil = if (renewed) 0L else System.currentTimeMillis() + RENEW_COOLDOWN_MS
+ return renewed
+ }
+
+ /** Set by a failed [renew]; see [canRenew]. */
+ @Volatile
+ private var renewBlockedUntil = 0L
+
/**
* The plan name recorded in the vaulted blob (`max`, `pro`, …), or null.
*
diff --git a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt
index eece1187..28538b0b 100644
--- a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt
+++ b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt
@@ -287,22 +287,7 @@ data class RateLimitInfo(
val overageInUse: Boolean = false,
val surpassedThreshold: Double? = null,
) {
- /**
- * Clamped 0..100 percent, or null if the binary didn't report utilization.
- *
- * **The event's scale is a 0..1 FRACTION, and it is not the same as `get_usage`'s.** Captured live from
- * `claude` 2.1.223 while claude.ai reported 92% of the weekly window spent:
- *
- * ```
- * {"status":"allowed_warning","rateLimitType":"seven_day","utilization":0.92,"surpassedThreshold":0.75}
- * ```
- *
- * `surpassedThreshold: 0.75` is the corroborating detail — thresholds are announced at 75%/85%, so the
- * companion field is unambiguously a fraction too. `sdk.d.ts` documents "Percentage of the window used,
- * 0-100" ONLY on the `get_usage` windows ([UsageWindow]); `SDKRateLimitInfo.utilization` carries no
- * such note, and the two really do differ. Reading the event on the 0..100 scale rendered a window at
- * 92% as **1%** — a quota bar that is not merely wrong but reassuring while the limit is about to hit.
- */
+ /** Clamped 0..100 percent, or null if the binary didn't report utilization. */
fun utilizationPercent(): Int? =
utilization?.let { Math.round(it * PERCENT).toInt().coerceIn(0, 100) }
diff --git a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt
index 9b2b82b7..bf24dc77 100644
--- a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt
+++ b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt
@@ -645,10 +645,16 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) :
* credential the user wrote by hand into the Settings environment. Nothing held → logged out by
* definition, and no process is started to re-ask a question we have already answered.
*
- * Deliberately does NOT harvest — see [absorbExistingLoginOnce].
+ * A vaulted login whose access token has expired but whose refresh token has not counts as an identity —
+ * it is one renewal away from live, and [renewVaultedCredential] performs that renewal off the EDT at
+ * launch time. Answering "signed out" here instead is what made every reboot end at the sign-in card.
+ *
+ * Deliberately does NOT harvest — see [absorbExistingLoginOnce] — and deliberately does not RENEW either:
+ * this runs on the EDT from [start], and renewal spawns a process.
*/
private fun hasCredential(settings: ClaudeSettings): Boolean {
if (dev.lain.claudejb.process.CredentialsVault.hasUsableToken()) return true
+ if (dev.lain.claudejb.process.CredentialsVault.canRenew()) return true
if (SecretStore.get(SecretStore.OAUTH_TOKEN) != null) return true
if (settings.getProviderApiKey(settings.provider).isNotBlank()) return true
val explicit = settings.resolveEnv()
@@ -691,6 +697,34 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) :
@Volatile private var ownLoginCheckedAt = 0L
+ /**
+ * Brings the vaulted subscription login back to life when its access token has expired, BEFORE the launch
+ * env is built. Blocking (process + network) — pooled thread only, which is why it lives in [launch] and
+ * not in [start].
+ *
+ * This is what makes a login survive a reboot. The access token the OAuth flow issues is good for hours;
+ * the refresh token beside it in the safe is good for weeks and is rotated at every renewal. Without this
+ * step the plugin held a perfectly persisted credential and still asked the user to sign in every
+ * morning — the credential had not been lost, it had merely expired with nothing allowed to spend it.
+ *
+ * @return whether this launch has an identity to run as. A renewal that fails does not condemn the
+ * launch: the ttl cache is dropped first so the fallback question ("does the BINARY hold its own
+ * login?") is asked again — a renewal can sign the binary in even when we fail to take custody of what
+ * it wrote, which is the normal case wherever it uses an OS store instead of a file.
+ */
+ private fun renewVaultedCredential(binary: File, settings: ClaudeSettings): Boolean {
+ // A sign-in owns `~/.claude/.credentials.json` from the browser leg until it is banked; renewing
+ // underneath it would take away the very file the flow is about to write.
+ if (login.inProgress) return true
+ if (!dev.lain.claudejb.process.CredentialsVault.needsRenewal()) return true
+ if (dev.lain.claudejb.process.CredentialsVault.renew(binary, settings.resolveEnv())) {
+ dev.lain.claudejb.process.AccountProfile.invalidate()
+ return true
+ }
+ ownLoginCheckedAt = 0
+ return hasCredential(settings)
+ }
+
/**
* Re-evaluates which screen this tab should be showing, from scratch. Called periodically while no
* session is running — BLOCKING (it stats the filesystem and reads the PasswordSafe), so pooled thread
@@ -882,6 +916,13 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) :
//
// The credential reaches the binary through the environment, WHOLE (CredentialsVault.envOverlay), and
// the binary keeps its own `~/.claude`. Nothing is relocated, symlinked or deleted.
+ //
+ // Renewal FIRST, and here rather than in start(): it spawns a process, start() runs on the EDT, and
+ // the env below has to be built from the credential we are about to hold — not the expired one.
+ if (!renewVaultedCredential(binary, settings)) {
+ edt { onLoginNeeded() }
+ return
+ }
val env = effectiveLaunchEnv(cachedEnv ?: settings.resolveEnv().also { cachedEnv = it })
// A stop()/dispose()/newer start() may have raced in during the (slow) env resolution. If so, this
// launch is stale — don't spawn an orphan process nothing will ever tear down.
@@ -2108,7 +2149,7 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) :
private fun onRateLimit(event: ClaudeEvent.RateLimit) {
val incoming = event.info
log.debug(
- "CC-TRACE rate_limit_event: window=${incoming.rateLimitType} status=${incoming.status}" +
+ "rate_limit_event: window=${incoming.rateLimitType} status=${incoming.status}" +
" utilization=${incoming.utilization} -> pct=${incoming.utilizationPercent()}",
)
val window = incoming.rateLimitType
diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt
index c27b6a49..b82719c9 100644
--- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt
+++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt
@@ -84,13 +84,15 @@ object JcefSessionData {
* "unknown" and "none used" are different claims and a bar cannot show both.
*/
private fun usageJson(session: ClaudeSession, report: UsageReport?): JsonObject? {
+ // EXPERIMENT (Lain's comma test): carry the decimals — do NOT round to Int — so we can see whether the
+ // frontend renders a fractional percentage with a comma (locale formatting in play) or a dot.
val fromReport = report?.windows?.map { (key, w) ->
- Window(key, w.utilization?.let { pctOf(it) }, w.resetsAt, exhausted = false)
+ Window(key, w.utilization, w.resetsAt, exhausted = false)
}.orEmpty()
val fromEvents = session.rateLimits
.filterKeys { key -> fromReport.none { it.key == key } }
.map { (key, info) ->
- Window(key, info.utilizationPercent(), info.resetsAt?.let(::isoOf), info.isExhausted)
+ Window(key, info.utilization?.let { it * 100 }, info.resetsAt?.let(::isoOf), info.isExhausted)
}
val windows = fromReport + fromEvents
if (windows.isEmpty() && report?.extra == null) return null
@@ -114,7 +116,7 @@ object JcefSessionData {
}
}
- private data class Window(val key: String, val pct: Int?, val resetsAt: String?, val exhausted: Boolean)
+ private data class Window(val key: String, val pct: Double?, val resetsAt: String?, val exhausted: Boolean)
/** The pay-as-you-go balance. Credits are minor units (`decimal_places`), not whole currency. */
private fun extraUsageJson(extra: ExtraUsage): JsonObject = buildJsonObject {
@@ -122,18 +124,10 @@ object JcefSessionData {
put("spent", extra.usedCredits?.let { it / TEN.pow(extra.decimalPlaces) })
put("limit", extra.monthlyLimit)
put("currency", extra.currency)
- put("pct", extra.utilization?.let { pctOf(it) })
+ put("pct", extra.utilization) // EXPERIMENT: raw, un-rounded, like the windows — so the decimal shows
put("limitReached", extra.spendLimitReached)
}
- /**
- * The wire scale is 0..100 (the SDK documents every `get_usage` window as "Percentage of the window
- * used, 0-100"); clamp, never crash. The former "accept 0..1 too" heuristic is deliberately gone: it
- * was undecidable at exactly 1.0 and rendered a genuine 1% as 100% — observed live, and reachable by
- * every user at the start of every freshly reset window. Same rule as [RateLimitInfo.utilizationPercent].
- */
- private fun pctOf(raw: Double): Int = Math.round(raw).toInt().coerceIn(0, 100)
-
/** Epoch seconds → ISO-8601, so event-sourced windows match the shape `get_usage` already returns. */
private fun isoOf(epochSeconds: Long): String =
java.time.Instant.ofEpochSecond(epochSeconds).toString()
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 11e6c211..7b110b1f 100644
--- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt
+++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt
@@ -31,12 +31,14 @@ object JcefState {
* because there the distinction between "unknown" and "unused" is worth the row.
*/
private fun compactUsageJson(session: ClaudeSession, usage: UsageReport?) = buildJsonArray {
+ // EXPERIMENT (Lain's comma test): carry the raw decimals here too, so the composer readout does not
+ // round to Int either — otherwise the decimal never shows and the test can't see a comma vs a dot.
val fromReport = usage?.windows.orEmpty().mapNotNull { (key, w) ->
- w.utilization?.let { key to normalizePercent(it) }
+ w.utilization?.let { key to it }
}
val fromEvents = session.rateLimits
.filterKeys { key -> fromReport.none { it.first == key } }
- .mapNotNull { (key, info) -> info.utilizationPercent()?.let { key to it } }
+ .mapNotNull { (key, info) -> info.utilization?.let { key to it * 100 } }
(fromReport + fromEvents).forEach { (key, pct) ->
addJsonObject {
put("key", key)
@@ -46,10 +48,6 @@ object JcefState {
}
}
- /** The wire has sent both 0..100 and 0..1 historically; accept either, clamp, never crash. */
- private fun normalizePercent(raw: Double): Int =
- (if (raw <= 1.0) raw * 100 else raw).toInt().coerceIn(0, 100)
-
fun stateJson(session: ClaudeSession, usage: UsageReport? = null): String {
val provider = session.provider
val mode = session.permissionMode
diff --git a/src/main/resources/jcef/app-composer.js b/src/main/resources/jcef/app-composer.js
index 5a0db28a..7814eb78 100644
--- a/src/main/resources/jcef/app-composer.js
+++ b/src/main/resources/jcef/app-composer.js
@@ -1034,9 +1034,9 @@
ro.appendChild(
h(
'span',
- { class: 'ro-item', title: String(win.label || '') + ' — ' + win.pct + '% used' },
+ { class: 'ro-item', title: String(win.label || '') + ' — ' + win.pct.toFixed(1) + '% used' },
h('span', { class: 'usage-dot ' + usageLevel(win.pct) }),
- h('span', { text: String(win.label || '') + ' ' + win.pct + '%' })
+ h('span', { text: String(win.label || '') + ' ' + win.pct.toFixed(1) + '%' })
)
);
}
diff --git a/src/main/resources/jcef/app-session.js b/src/main/resources/jcef/app-session.js
index bc141339..eca50f2e 100644
--- a/src/main/resources/jcef/app-session.js
+++ b/src/main/resources/jcef/app-session.js
@@ -129,7 +129,7 @@
var level = exhausted ? 'lvl-high' : known ? usageLevel(pct) : 'lvl-low';
var fill = h('div', {
class: 'usage-fill ' + level,
- style: { width: (known ? pct : 0) + '%' },
+ style: { width: (known ? pct.toFixed(1) : 0) + '%' },
});
var reset = resetIn(resetsAt);
return h(
@@ -139,7 +139,8 @@
'div',
{ class: 'usage-head' },
h('span', { class: 'usage-label', text: label == null ? '' : String(label) }),
- h('span', { class: 'usage-pct', text: known ? pct + '% used' : '—' })
+ // toFixed(1): one decimal, and it also kills the IEEE-754 tail (0.28*100 = 28.000000000000004).
+ h('span', { class: 'usage-pct', text: known ? pct.toFixed(1) + '% used' : '—' })
),
h('div', { class: 'usage-track' }, fill),
reset ? h('div', { class: 'usage-reset', text: reset }) : null
diff --git a/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultHeadlessTest.kt
index 5ce1a12f..82e3ac95 100644
--- a/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultHeadlessTest.kt
+++ b/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultHeadlessTest.kt
@@ -44,6 +44,19 @@ class CredentialsVaultHeadlessTest : BasePlatformTestCase() {
private fun blob(token: String, inMs: Long) =
"""{"claudeAiOauth":{"accessToken":"$token","expiresAt":${System.currentTimeMillis() + inMs}}}"""
+ /** The real shape the binary writes: an access token AND the refresh token that outlives it. */
+ private fun renewable(accessInMs: Long, refreshInMs: Long): String {
+ val now = System.currentTimeMillis()
+ return """
+ {"claudeAiOauth":{"accessToken":"stale","expiresAt":${now + accessInMs},
+ "refreshToken":"rt","refreshTokenExpiresAt":${now + refreshInMs},
+ "scopes":["user:profile","user:inference"]}}
+ """.trimIndent()
+ }
+
+ private val hour = 60 * 60 * 1000L
+ private val day = 24 * hour
+
fun `test harvest moves the file into the safe and deletes it`() {
file.parentFile?.mkdirs()
file.writeText("""{"claudeAiOauth":{"accessToken":"secret-token"}}""")
@@ -108,13 +121,61 @@ class CredentialsVaultHeadlessTest : BasePlatformTestCase() {
assertFalse(file.exists())
}
- fun `test an expired token is not an identity — it cannot be refreshed without the file`() {
+ fun `test an expiring token is not handed out, and with no refresh token it is not an identity`() {
SecretStore.set(SecretStore.CREDENTIALS_JSON, blob("stale-token", inMs = 60_000))
assertTrue("an expiring token must not be handed out", CredentialsVault.envOverlay(emptySet()).isEmpty())
- // Refreshing needs the binary to rewrite its own file, which never happens now. So this counts as
- // signed out and the card comes back, instead of a session that fails its first turn.
assertFalse(CredentialsVault.hasUsableToken())
+ // Nothing to renew from: this blob carries an access token and nothing else.
+ assertFalse(CredentialsVault.canRenew())
+ assertFalse(CredentialsVault.needsRenewal())
+ }
+
+ fun `test an expired token with a live refresh token is still an identity, pending renewal`() {
+ // THE REBOOT CASE. The access token is issued for hours, so an overnight restart always lands here;
+ // answering "signed out" is what put the sign-in card in front of the user every morning.
+ SecretStore.set(SecretStore.CREDENTIALS_JSON, renewable(accessInMs = -60_000, refreshInMs = day * 20))
+
+ assertFalse(CredentialsVault.hasUsableToken())
+ assertTrue("a live refresh token is an identity one renewal away", CredentialsVault.canRenew())
+ assertTrue(CredentialsVault.needsRenewal())
+ }
+
+ fun `test a live access token needs no renewal`() {
+ SecretStore.set(SecretStore.CREDENTIALS_JSON, renewable(accessInMs = 6 * hour, refreshInMs = day * 20))
+
+ assertTrue(CredentialsVault.hasUsableToken())
+ assertFalse("nothing to renew while the token is live", CredentialsVault.needsRenewal())
+ }
+
+ fun `test an expired refresh token cannot renew`() {
+ SecretStore.set(SecretStore.CREDENTIALS_JSON, renewable(accessInMs = -60_000, refreshInMs = -day))
+
+ assertFalse(CredentialsVault.canRenew())
+ assertFalse("a spent refresh token must lead to the sign-in card", CredentialsVault.needsRenewal())
+ }
+
+ fun `test renewal needs the scopes the binary demands`() {
+ // The non-interactive path is driven by CLAUDE_CODE_OAUTH_SCOPES alongside the refresh token, and a
+ // blob that cannot state the grant it was issued under is not renewable — better to say so here than
+ // to spawn a process that exits 1.
+ SecretStore.set(
+ SecretStore.CREDENTIALS_JSON,
+ """{"claudeAiOauth":{"accessToken":"stale","expiresAt":0,"refreshToken":"rt","scopes":[]}}""",
+ )
+
+ assertFalse(CredentialsVault.canRenew())
+ }
+
+ fun `test a refresh token with no recorded expiry is given the benefit of the doubt`() {
+ SecretStore.set(
+ SecretStore.CREDENTIALS_JSON,
+ """{"claudeAiOauth":{"accessToken":"stale","expiresAt":0,"refreshToken":"rt","scopes":["a"]}}""",
+ )
+
+ // The endpoint is the authority on whether it is still good; a wrong guess costs one failed renewal,
+ // refusing costs a sign-in nobody needed.
+ assertTrue(CredentialsVault.canRenew())
}
fun `test an explicit credential outranks the vaulted one`() {