Skip to content

feat: onboarding, native sign-in and full session telemetry - #39

Merged
serialexperimentslainnnn merged 11 commits into
developfrom
feature/release_v5.0.0
Aug 6, 2026
Merged

serialexperimentslainnnn merged 11 commits into
developfrom
feature/release_v5.0.0

Conversation

@serialexperimentslainnnn

Copy link
Copy Markdown
Owner

Closes the 5.0.0 onboarding work: a tab with no claude binary and no credential is now a guided flow instead of a spinner, and the session dashboard reports the complete picture.

What lands

Onboarding. Install card with one button per official route for the OS (Linux script + apt/dnf/apk by distro, macOS script + brew, Windows ps1/winget/cmd), each beside the exact command, copyable. Manual path entry validated by running --version. Sign-in card with the subscription flow (full OAuth consent) and an API-key route.

Ordered flow. Install → sign in → loading → chat. The chat is reachable only while the process is running and answering, so the first frame is drawn with the command list, model catalogue and account in hand. Any loss — binary uninstalled, credential gone, process exited — stops the session and returns to the matching screen.

Credential handling. The subscription login is held in the IDE's password safe, never on persistent storage. The session hands it to the binary through a private CLAUDE_CONFIG_DIR on tmpfs (owner-only, credential 0600), with the real configuration symlinked in so /resume, settings and skills are untouched. Refresh happens there and returns to the safe at teardown.

Why that shape, measured on 2.1.223 — one account, one moment, identical launch argv:

identity subscription_type rate_limits_available windows
config dir max true five_hour 11%, seven_day 94%
env token null false null

An environment token authenticates but reports no plan, no account and no rate-limit windows — so the config dir is what the dashboard requires, and tmpfs is what keeps it off a disk.

API keys live in their own per-provider slot (the mechanism DeepSeek already uses), so the card and Settings ▸ Provider are one credential and no provider can overwrite another's. Each key's one-time approval is recorded, since a --print session has nobody to ask.

Plan bars read each source on its own scale: the live rate-limit events carry a 0..1 fraction, the on-demand usage reply 0..100.

Verification

  • 725 JVM tests, 102 frontend, detekt, spotless, prettier — all green
  • ./gradlew checkDrift — no drift at claude 2.1.223 / SDK 0.3.223
  • npm audit --omit=dev — 0 vulnerabilities
  • Manual, in-IDE: install card, path validation, sign-in through the browser, API-key route, log out, session restart, plan bars against claude.ai's own figures

🤖 Generated with Claude Code

serialexperimentslainnnn and others added 11 commits August 6, 2026 04:26
v5.0.0 shipped with an UNVERIFIED tag. Three things had to be wrong at once, and
all three were:

  - gen-ci-signing-key.sh generated the key with Name-Real but no Name-Email, so
    its uid carried no address at all. GitHub reported it as `emails=` — empty.
  - bootstrap-ci.sh never registered the key on the GitHub ACCOUNT. It certified
    the key with the YubiKey, which is a different mechanism: certification makes
    `gpg --verify` meaningful to a human, registration is what the "Verified"
    badge reads. The two were conflated.
  - release.yml tagged as `github-actions[bot]@users.noreply.github.com`, an
    address that cannot appear on anyone's key.

GitHub marks a signature verified only when the tagger email, an email on a uid
of a registered key, and a verified account email all agree. Any one of the above
defeats it permanently.

The address is now DERIVED, never written down. gen-ci-signing-key.sh reads it
from the maintainer key via `git config user.signingkey`; release.yml reads it
from the signing key it just imported. Each link derives from the previous one,
so rotating the key is sufficient on its own and nothing can drift out of step.
It also keeps the address out of the repository — this project deliberately
publishes no contact email anywhere (CHANGELOG 5.0.0), and a committed script is
published.

Both now fail loudly where they used to continue: the workflow aborts if the
imported key has no email rather than producing another unverifiable tag, and the
generator aborts rather than minting another unusable key.

Also in bootstrap-ci.sh: it no longer re-adds a required reviewer (publication is
automatic on merge to main, and on a single-collaborator repository the merge
already is the human act), and it adds `main` to the deployment branch policy —
without that entry the job is rejected before the workflow is even reached, which
is how the first attempt failed.

Finally, the GitHub Release now carries CHANGELOG.md instead of RELEASE_NOTES.md.
The latter is the Marketplace copy — emoji-led, second person — and that register
belongs on a storefront where someone is deciding whether to install, not in
front of a person who arrived at a release page because something broke.
Different readers, different documents: build.gradle.kts still feeds the
Marketplace panel from RELEASE_NOTES.md. The extracted section is ~27 KB against
a 125 000-character limit, and an empty extraction now fails the release rather
than publishing blank notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`./gradlew checkDrift` reports no drift: the versions advanced and the protocol
surface is unchanged, so this is a baseline bump and nothing else. `npm audit
--omit=dev` stays at zero.

NB the binary is a system-wide install here (`/usr/bin/claude`) while checkDrift
defaults to `~/.local/bin/claude`, so it needs `-PclaudeBinary=/usr/bin/claude`
on this machine. The weekly drift.yml installs the CLI itself and is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tab opened without the `claude` binary showed a loading screen that faded into
an empty tab explained only by a toast. It now shows an install card: one button
per OFFICIAL route for the OS (Linux script + apt/dnf/apk by distro detection,
macOS script + brew, Windows ps1/winget/cmd), each next to the exact command it
runs, copyable — where a network blocks one route the command is the fallback. A
typed path accepts a file or a directory and is validated by running `--version`
and requiring the answer to name Claude Code.

Sign-in is a card rather than a command, and comes BEFORE the loading screen:
verifying credentials needs no session, so launching one we already know is
unauthenticated only buys a spawned process and a turn that fails for a reason
known at click time. The subscription flow runs `auth login` (NOT `setup-token`,
whose reduced grant drops scopes Claude Code exercises — file upload, which is
what a pasted attachment travels on) under a PTY, driven by the card through a
`LoginUi` seam. The binary opens the browser and captures the callback itself, so
the code field is an optional fallback on the same screen.

The three boot states are now RE-DERIVED every few seconds rather than decided
once inside `start()`. That is the fix for a tab opened before the install
keeping its stale answer until it was closed and reopened.

CREDENTIALS. `auth login` leaves `~/.claude/.credentials.json` in plaintext,
readable by anything running as the user and shared with the terminal CLI.
CredentialsVault harvests it into the IDE PasswordSafe and deletes it, and
NOTHING writes it back — the credential reaches the binary as
CLAUDE_CODE_OAUTH_TOKEN, which takes precedence over the binary's own store
(verified on 2.1.223: `auth status` flips authMethod claude.ai to oauth_token).
Two costs, both accepted rather than hidden: only the binary can spend the
refresh token and it does so through that file, so an expired credential counts
as signed out and the card returns; and the oauth_token identity is reduced, so
the account email is unavailable and the plan is read from the vaulted blob.

The vault refuses to run against a real home from a test JVM. That is not
caution: the integration tests start a real session, whose launch() harvested the
developer's own credentials into a throwaway test safe and deleted them —
invisible while the file was still written back, destructive the moment it was
not.

API keys go to their own per-provider slot, the same mechanism DeepSeek uses, so
the card and Settings are one credential and no provider can overwrite another's.
ApiKeyApproval fixes a valid key being rejected: the binary demands each key be
approved once and records it in ~/.claude.json, and a --print session has nobody
to ask — so entering it in the card writes that approval, and the key is verified
before being stored at all.

Also: utilization is a 0-100 percentage, not a fraction (1% rendered as a full
window, reachable by everyone at the start of every reset window); meters poll
once per second with no overlapping round-trips, since the control channel is
shared with tool traffic and a pile-up made tool cards linger; and everything
per-process is re-requested on every launch, not just the first, so the dashboard
fills after a sign-out/sign-in without needing a prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The session hands the binary its credential through a private CLAUDE_CONFIG_DIR
on tmpfs — memory, wiped at reboot — owner-only, credential 0600, with the real
configuration (session history, settings, skills, account profile) symlinked in
so /resume and everything else behave unchanged.

That is what the dashboard needs: authenticated through an environment token the
binary reports no plan, no account and no rate-limit windows at all. Measured on
2.1.223 against one account, one moment, identical launch arguments:

  config dir  -> subscription_type "max", rate_limits_available true,
                 five_hour 11%, seven_day 94%
  env token   -> subscription_type null,  rate_limits_available false,
                 rate_limits null

Token refresh happens in that directory and the refreshed value returns to the
safe at teardown, so a login stays alive rather than expiring into a card. With
no tmpfs available the env token is used instead — never a credential written
quietly to a real disk.

Also here: the plan bars read each source on the scale it uses (live rate-limit
events carry a 0..1 fraction, the on-demand usage reply 0..100 — captured live
while claude.ai showed 92%); the chat is reachable only while the process is
running and answering, with install / sign-in / loading / chat as one ordered
flow that walks backwards on any loss; the credentials file is absorbed once at
startup rather than on every poll, which is what a sign-in needs to complete
through the browser; and CC-TRACE debug lines record what the binary answered,
what the decode made of it, and what the dashboard was handed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Anthropic Console route to the sign-in card as a BUTTON beside the
subscription one: its OAuth consent carries `org:create_api_key`, so the binary
provisions the key for Claude Code itself and an organisation no longer has to
distribute a pasted credential. Typing a key by hand becomes the exception and
moves behind a disclosure.

- `RuntimeConfigDir` builds a per-session owner-only temp dir on every platform.
  The `/dev/shm` special case bought a shorter-lived file on ONE OS in exchange
  for the plugin behaving differently on the two most people use; the encrypted
  safe is the persistent memory, the dir is a disposable projection of it. On a
  machine with no `~/.claude.json` the account banked at sign-in is written back
  out, so the binary knows who it is and the dashboard can name the account.
- `SecretStore.ACCOUNT_PROFILE` sits OUTSIDE the mutually-exclusive auth modes:
  learning the user's email must not evict the credential next to it.
- `ClaudeSettings.signedOut` makes Log out mean something. With no credential of
  its own the plugin rides the binary's login (which is what makes it work on
  macOS), so clearing the safe alone left the next session signed in again.
- `ClaudeLoginFlow` no longer writes into or kills the PTY: `auth login` is a
  one-shot command that exits 0 on its own, so anything we send can only turn a
  clean success into a failure. It now logs the full transcript, ANSI stripped
  and tokens masked via `LoginOutputParser.redactSecrets` — this regression left
  nothing behind but an exit code, which is how it stayed invisible.
- `TerminalLauncher.loginCommand` takes the subcommand as argv (`--console`,
  `--sso`); the terminal tab already got a real argv list, so `loginArgv` goes.

728 JVM tests, 104 frontend, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tool window held every chat as a content tab, laid out by the platform's
`TabContentLayout`, which does not scroll: past a handful of chats it stops
drawing the earliest ones and buries them behind a `⌄` popup. The tool window
now holds ONE content and every chat is a `TabInfo` in the plugin's own
`ChatTabsPanel`, built on `JBTabs` — the editor's widget, whose single-row
layout scrolls.

The close button is the part that took five versions, so the reason is worth
recording: `ActionPanel` — the thing that turns a tab's action group into the
little button — builds its buttons through a traverser that filters on
`actionUpdateThread == ActionUpdateThread.EDT`, and `AnAction` answers `BGT` by
default. An action that does not declare EDT is dropped on the floor and the tab
is simply drawn without a close button, at any width, hovered or not. The
platform's own editor-tab `CloseTab` declares it; now so does ours.

Two more things guard it, both read off the platform rather than guessed:
`setTabLabelActionsAutoHide(false)` so it is drawn without hovering, and
`setSupportsCompression(false)` because `TabLabelLayout` bounds the EAST
component — which IS the action panel — to 0x0 whenever it has to squeeze a
label below its preferred width. Middle-click closes too.

Sign out moves onto the gear menu (`SignOutAction`), delegating to the one
logout sequence in `OnboardingController`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`koverVerify` failed on the PR, not a test: the auth commit added ~200 lines to
`ClaudeSession`/`LoginCoordinator` and pushed the `session` package to 64.61%,
under its 65% floor. Fixed by covering behaviour that was genuinely untested,
not by lowering the gate.

- `LoginModeTest` pins the argv of each sign-in route, including through
  `TerminalLauncher.loginCommand`. The flag IS the route (`--console` bills API
  usage against an organization), so a mix-up signs the user into the wrong
  thing while looking like a working login.
- `RollbackManagerHeadlessTest` covers what the Diff History panel lists: the
  join of transcript rows and captured snapshots (a row with no snapshot cannot
  be reverted, so it must not be offered), a real revert with its read-state
  reseed, and the project-root refusal — a transcript can name any path, and a
  rollback must never restore stale contents outside the tree.

session package 64.61% -> 65.35%. 737 JVM tests, 104 frontend, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The class doc still said "in RAM" and "tmpfs (`/dev/shm`)" over code that now
builds an ordinary per-session temp dir — the worst kind of comment, the one that
does not fail and gets believed. It now states what is actually true and what is
NOT claimed: the only persistent copy is the encrypted safe, and what lands in
the dir is a plaintext owner-only projection of it for the life of the session.
Better than a permanent `~/.claude/.credentials.json`, but not "never touches a
disk" — the phrasing that bought the `/dev/shm` special case in the first place.

Also drops the `SystemInfo` import left orphaned by removing `isAvailable()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects with one root: the plugin handed the binary only `accessToken`, so
the session carried no OAuth scopes. The SDK is explicit — `get_usage` answers
`rate_limits_available: false` on a "missing profile scope" — so every plan
meter went dark. That was misdiagnosed as "the binary only reports this from
its own config directory", and the fix built on that premise was a relocated
`CLAUDE_CONFIG_DIR` full of symlinks into `~/.claude`, torn down with
`dir.deleteRecursively()`. `FileTreeWalk` follows directory symlinks, so every
stop() — a logout, a closed tab, a closed IDE — descended through them and
emptied the real directories: projects (every past conversation, in every
project), skills, sessions, cache. It destroyed a user's history.

- `CredentialsVault.envOverlay` hands over the blob field by field: refresh
  token, `CLAUDE_CODE_OAUTH_SCOPES`, subscription type, rate-limit tier and the
  account. Absent fields are omitted, never blanked. `SDK_HAS_OAUTH_REFRESH` is
  deliberately not claimed — this host cannot spend a refresh token. Verified
  live: the plan-limit panel fills.
- `RuntimeConfigDir` deleted outright. No config dir, no symlinks, no
  recursion. The binary keeps its own `~/.claude`.
- `SessionStore.delete` and the "Delete Previous Session…" menu item removed.
  Transcripts are the user's; the plugin has no business removing them.
- `NoFileDeletionContractTest` pins it at the source: recursive deletion banned
  outright, single-file deletion only in `CredentialsVault`, and only the
  credentials file it harvested.
- Account card: `auth status` is invoked WITHOUT `--json` (it answers JSON on
  its own; the unknown flag was a non-zero exit, i.e. no answer at all), every
  field it emits is modelled, and the reply is banked verbatim in the IDE safe
  as `CLAUDE_AUTH_STATUS` — but only when it names the account, so an anonymous
  reply cannot overwrite a good one. Ask first, harvest second: the credentials
  file is the only thing that can answer who is signed in, so taking custody of
  it before asking left Email and Organization permanently blank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The account probe went out with `--json`, a flag `auth status` does not know, so
it exited non-zero and the plugin read that as "unknown" — no identity at all.
With the flag gone the reply is banked verbatim in the IDE safe, and the order
is the one that makes it answerable: ask WHO first, take custody of the
credentials file second. Reversed, the only thing that could answer is already
gone, which is why the dashboard's Email and Organization rows stayed blank
while Plan and Provider filled in from other sources.

- The identity question is asked only when somebody IS signed in, and never
  from a test JVM: it resolves the developer's own home and spawns the binary.
- `AuthCli.run` destroys a process that outlives its timeout. Without it the
  timeout only stopped us waiting, leaving the process and its stream readers
  alive — a leaked-thread failure attributed to whichever test ran next.
- `accountJson` picks the first present candidate through one helper instead of
  five nested elvis chains (detekt: complexity 19 > 15).
- `SessionTranscriptReaderParseTest` covers the restore path the integration
  suite never reached: prompt shapes, thinking vs text, tool_result attribution
  and error tagging, the command tag applied from an earlier line, the tail cap
  dropping an orphan output, corrupt lines, metadata first-wins, and the
  newest-first session listing. session package 64.05% -> 65.35%.

749 JVM tests, 104 frontend, detekt/spotless/eslint/prettier clean, audit 0,
verifyPlugin Compatible IC-251 -> IU-262.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@serialexperimentslainnnn
serialexperimentslainnnn merged commit 92ad113 into develop Aug 6, 2026
10 checks passed
@serialexperimentslainnnn
serialexperimentslainnnn deleted the feature/release_v5.0.0 branch August 10, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant