From 516c0c11aa8c00eb3c2ea469b5fbb7a15ae34bbe Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 19:30:47 +0200 Subject: [PATCH 1/4] chore: trigger the release pipeline Empty commit to open a fresh PR into main and drive release.yml once GitHub Actions recovers from the outage. No code change. Co-Authored-By: Claude Opus 5 (1M context) From e9ef8bdda213feed50527272cb48781e9b19d0fa Mon Sep 17 00:00:00 2001 From: Lain Date: Mon, 10 Aug 2026 13:01:15 +0200 Subject: [PATCH 2/4] fix(auth): renew the vaulted login instead of asking again The subscription login did not survive a restart, on Linux and on Windows alike, and the cause was not persistence. The credential is in the IDE PasswordSafe and therefore in the OS store (KWallet or GNOME Keyring through the Secret Service, Keychain, Credential Manager), and it was still there after the reboot. What expired was the access token inside it - `auth login` issues one good for about ten hours, so any restart the next day found a perfect credential that authenticated nothing, `hasUsableToken()` answered false, and false meant signed out. The blob always carried a refresh token good for weeks, and nothing was allowed to spend it, since spending it means the binary rewriting `~/.claude/.credentials.json` - the file the vault exists to remove. The way out is in the binary and is a first-class path. With the environment carrying CLAUDE_CODE_OAUTH_REFRESH_TOKEN and CLAUDE_CODE_OAUTH_SCOPES, `claude auth login` takes a dedicated non-interactive branch, mints a credential into its own store and exits. Verified against 2.1.223 that the branch is genuinely non-interactive - an invalid refresh token fails on the HTTP round-trip and exits 1, with no browser and no TTY wait. So renewal is the binary's job, exactly as it always was, and the plugin's job stays what it was - capture the account while the config is freshest, harvest the credential off the disk, delete it. The plugin still holds no OAuth client, calls no token endpoint and never writes that file back. This is one bug rather than two. The binary's default credential store is its `plaintext` provider on every platform, so the vault path and the expiry are identical everywhere. No platform-specific code was needed; the only Windows-specific care is that the renewal environment strips CLAUDE_CODE_OAUTH_TOKEN case-insensitively, since environment names are case-insensitive there. An expired-but-renewable blob now counts as an identity (`CredentialsVault.canRenew`), the renewal runs off the EDT in `launch()` before the 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 failure arms a five-minute cooldown because the boot watcher polls every three seconds. Also drops a leftover CC-TRACE prefix from a rate-limit debug log. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 33 +++++++ CLAUDE.md | 2 +- RELEASE_NOTES.md | 14 +++ build.gradle.kts | 2 +- .../dev/lain/claudejb/process/AuthCli.kt | 37 ++++++- .../lain/claudejb/process/CredentialsVault.kt | 98 +++++++++++++++++-- .../lain/claudejb/session/ClaudeSession.kt | 45 ++++++++- .../headless/CredentialsVaultHeadlessTest.kt | 67 ++++++++++++- 8 files changed, 281 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fd102f..47f33cbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ 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. + + 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..2ff1eedc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,17 @@ +## 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. + ## 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/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/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`() { From 7660edab5fd6ad36fbac6f4a5adbcc6e058a878b Mon Sep 17 00:00:00 2001 From: Lain Date: Mon, 10 Aug 2026 13:01:33 +0200 Subject: [PATCH 3/4] feat(ui): show plan-limit percentages with one decimal The usage windows and the extra-credit balance were rounded to a whole number on the Kotlin side before they ever reached the web app, so the dashboard bars and the composer dots could only ever read as integers. The percentage now travels as a Double and the front end formats it with `toFixed(1)`, which also makes the locale question visible - whether the decimal separator renders as a comma or a dot is now something the UI can be observed doing rather than guessed at. Removes `JcefSessionData.pctOf` and `JcefState.normalizePercent` along with it, since neither has a caller once the rounding moves to the display layer. The clamping they performed goes with them; the event-sourced windows are still multiplied by 100 exactly as `RateLimitInfo.utilizationPercent` did. Co-Authored-By: Claude Opus 5 (1M context) --- .../dev/lain/claudejb/protocol/Protocol.kt | 17 +---------------- .../lain/claudejb/ui/jcef/JcefSessionData.kt | 18 ++++++------------ .../dev/lain/claudejb/ui/jcef/JcefState.kt | 10 ++++------ src/main/resources/jcef/app-composer.js | 4 ++-- src/main/resources/jcef/app-session.js | 5 +++-- 5 files changed, 16 insertions(+), 38 deletions(-) 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/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 From 1e9648387af9d04bea556c24ba38bb1091da11fe Mon Sep 17 00:00:00 2001 From: Lain Date: Mon, 10 Aug 2026 13:04:20 +0200 Subject: [PATCH 4/4] docs(release): say which sign-in the renewal fix covers The 5.0.1 notes described the fix without naming the credential it applies to, which leaves an API-key user unable to tell whether it concerns them. It does not. An Anthropic API key is a different identity in a different slot (`providerApiKey:anthropic` in the same PasswordSafe, not `CLAUDE_CREDENTIALS_JSON`), it has no expiry and no refresh token, so nothing was lost across a restart and nothing is renewed now. `CredentialsVault.renew` reads the `claudeAiOauth` blob and nothing else, and `envOverlay` withdraws entirely when an API key is present. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++++++ RELEASE_NOTES.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f33cbe..b9ee0047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2ff1eedc..79d56e6c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -12,6 +12,12 @@ terminal and without you. Nothing about how it's stored changes: the credential 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