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`() {