Skip to content

Keep trying an overnight sign-in instead of giving up on it - #92

Merged
popen2 merged 2 commits into
mainfrom
claude/frost-refresh-notification-bug-bxkzh7
Sep 14, 2026
Merged

popen2 merged 2 commits into
mainfrom
claude/frost-refresh-notification-bug-bxkzh7

Conversation

@popen2

@popen2 popen2 commented Sep 14, 2026

Copy link
Copy Markdown
Owner

A refresh that came due while nobody was at the machine ended the same way every
time: the page needed the user, Frost showed a login window (or opened a browser
tab) into an empty room, AWS expired the device code ten minutes later, and
scheduleAfterFailure() then stopped refreshing altogether and raised "Sign-in
Needed". By morning that notification pointed at a code that had died in the
night, the tray said "Sign-in needed" with nothing scheduled, and credentials had
been expired for hours — with Auto-open browser set, which is the setting that
asks Frost not to leave it to the user.

Not retrying was the right fix for #83 (a login page every half second, a tab a
minute in default-browser mode) and the wrong one for this. The pile-up came from
retrying visibly with nobody there, so that is what changes.

What this does

Continuous refreshing is the premise of the app, so there is no backoff of any
kind
on a login nobody finished. AWS keeps a device code alive for about ten
minutes; when one dies the next sign-in starts at once, all night, and the pacing
is AWS's rather than ours.

  • An unattended login is held, not shown and not abandoned. When the page
    turns out to need a person and nobody is at the machine
    (powerMonitor.getSystemIdleTime() against AWAY_IDLE_SEC), the attempt is
    parked: nothing on screen, and the page keeps being driven — so a slow
    identity provider or a dropped network still completes the refresh with nobody
    the wiser. The live page is handed over the moment somebody is there, on the
    next poll. HANDOVER_MIN_CODE_LIFE_MS keeps a code with seconds left from
    being handed to a user who would watch it die; the replacement is immediate, so
    they get a fresh page instead.
  • Presence is read wherever it matters, not captured once per run: a
    ten-minute login can start with nobody there and end with the user watching it.
  • MIN_LOGIN_CYCLE_MS (30s) is the only floor, and is not a backoff — it
    never grows, and nothing waits for it unless a login fails the instant it
    starts, which would otherwise spin attempts as fast as AWS could refuse them.
  • A parked run stays reachable. It holds isWorking, which would have made
    "Refresh now" and the hotkey no-ops all night and the tray read "Refreshing…"
    while a page sat waiting for a password. A refresh the user asks for now shows
    the login Frost is holding, and the tray says Sign-in needed — waiting for
    you
    .
  • The "Sign-in Needed" notification is gone. It existed because Frost stopped
    trying and that was invisible; the replacement login is the notice now, and a
    notification on top of it is the thing this change is meant to stop.
  • Notification mode and manual approval cannot get through unattended at all,
    so they no longer burn a device code to find that out: Frost waits for the user
    and starts then, recording one run rather than one every half minute.
  • Only the user ending a login themselves — closing the window, refusing it at
    the identity provider — still stops the replacements.
  • retryDelayMsAfterError() splits into errorRetryDelayMs() (unchanged 1m→30m
    for failures with no login page involved) and nextLoginAttemptDelayMs(), with
    loginRetryAction() deciding each tick. AGENTS.md and the docs pages follow.

The first commit here kept Frost trying but on a 5-minute-to-hourly backoff, and
ended an attempt as soon as it needed a human; the second replaces both with the
above. Reviewing the second commit alone is enough to see the final behaviour.

Verification

npm run build, npm run lint, and a headless harness driving dist/aws-sso.js
against stubbed electron (including powerMonitor idle time), a faked SSO OIDC
client and a fake clock: 53 checks over ten scenarios, plus the real
dist/tray.js rendering the held-login label.

  • Eight simulated hours away with a page that needs a human: 46–49 device codes,
    worst gap between attempts 10m30s (the code's life plus the poll that notices),
    nothing shown, nothing notified, and the login page appears 10s after the first
    keypress.
  • A slow identity provider coming through mid-hold completes the refresh
    unattended.
  • Six hours away in default-browser mode: zero tabs, then exactly one when the
    user returns.
  • Notify mode, four hours away: zero device codes, one run.
  • Against main, the same night ends with one dead login page, one notification,
    and no sign-in until the user presses the hotkey.

Costs, deliberately accepted: a hidden login window and its 400ms approval scan
live continuously while a login is held rather than only during a refresh, and
the Activity page gains an entry per device code (~6/hour) while a machine is
away with a sign-in that needs a human.

Not checked here: real notifications, a real tray, and idle-time reporting on a
real desktop — the harness stubs all three. On a Linux session where
getSystemIdleTime() is unimplemented it reports 0, which reads as "somebody is
here", so those sessions get the visible login page rather than a held one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PBe4cVzZUazesEcLLaktV4

A refresh that came due while nobody was at the machine ended the same way
every time: the page needed the user, Frost showed a login window (or opened a
browser tab) into an empty room, AWS expired the device code ten minutes later,
and scheduleAfterFailure() then stopped refreshing altogether and raised
"Sign-in Needed". By morning that notification pointed at a code that had died
in the night, the tray said "Sign-in needed" with nothing scheduled, and
credentials had been expired for hours — with Auto-open browser set, which is
the setting that asks Frost not to leave it to the user.

Not retrying was the right fix for #83 (a login page every half second, a tab a
minute in default-browser mode) and the wrong one for this. The pile-up came
from retrying *visibly* with nobody there, so that is what changes: an
abandoned login is retried, and an attempt made while the machine is idle is
silent.

- schedule.ts: only the user ending a login themselves (`cancelledByUser`)
  still stops the retries. A login nobody finished gets its own backoff,
  5 minutes doubling to hourly, and `loginRetryAction()` decides each tick:
  the backoff says try again anyway, the user turning up says try again now.
- aws-sso.ts: every refresh Frost starts itself now carries whether anyone is
  there (`powerMonitor.getSystemIdleTime()` against AWAY_IDLE_SEC); the ones
  the user asks for are attended by definition. An unattended refresh still
  runs — that is what recovers a silent approval beaten by a slow identity
  provider — but handOverToUser() ends the attempt rather than showing a window
  or opening a tab, and notify mode and manual approval end before a device
  code is even issued. `windowOpen` becomes an `abort` error so both endings
  travel the same path out of the poll loop.
- The "Sign-in Needed" notification is now once per streak and only when
  somebody was there to miss the login, and it names the next attempt. A user
  who was away gets the retry itself instead of an unread notification.
- The tray says "Sign-in needed — retrying in 10 minutes" rather than a bare
  "Sign-in needed", which read as Frost having given up — because it had.
- AGENTS.md (schedule.ts rules, automatic approval, verification) and
  docs/docs/{credential-refresh,login,settings-behavior,troubleshooting,
  app-window}.html describe the new behaviour.

Verified with `npm run build`, `npm run lint`, and a headless harness driving
dist/aws-sso.js against stubbed electron (idle time included), a faked SSO OIDC
client and a fake clock: 46 checks over eight scenarios — away with popup and
with default browser, notify mode, automatic approval off, a transient failure
healing itself unattended, an attended login left unfinished, a window the user
closed, a network failure, and a post-token failure — plus the real dist/tray.js
rendering the retry label. One simulated night (away from 8pm, token due at 2am,
the page needing the user) against main and against this branch:

                                    main   this
  login pages shown to nobody          1      0
  browser tabs opened to nobody        1      0   (default-browser mode)
  notifications raised overnight       1      0
  signed in after sitting down      never    30s, unprompted

Not checked here: real notifications, a real tray, and idle-time reporting on a
real desktop — the harness stubs all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBe4cVzZUazesEcLLaktV4
Follow-up on the same overnight failure: the first pass kept Frost trying, but
paced the retries 5 minutes → hourly, and ended the attempt the moment a page
turned out to need a human. Both work against what the retry is actually for.
Continuous refreshing is the premise of the app, so a gap between a device code
dying and the next one starting is a gap in the credentials, and an attempt
ended early is one that cannot come through on its own.

So there is no Frost-side delay left. AWS's device code lives about ten minutes;
when one dies the next sign-in starts at once, all night, and the pacing is
AWS's rather than ours.

- An unattended login is no longer aborted when the page needs the user. It is
  *parked*: nothing is shown, the page keeps being driven — so a slow identity
  provider or a dropped network still completes it with nobody the wiser — and
  the live page is handed over the moment somebody is there, on the next poll.
  HANDOVER_MIN_CODE_LIFE_MS keeps a code with seconds left from being handed to
  a user who would watch it die; the replacement is immediate, so they get a
  fresh page instead.
- Presence is read wherever it matters instead of once per run: a ten-minute
  login can start with nobody there and end with the user watching it. The
  `attended` flag that threaded through refresh() is gone.
- MIN_LOGIN_CYCLE_MS (30s) is the only floor, and it is not a backoff — it never
  grows, and nothing waits for it unless a login fails the instant it starts,
  which would otherwise spin attempts as fast as AWS could refuse them.
- A parked run held `isWorking`, which would have made "Refresh now" and the
  hotkey no-ops all night and the tray read "Refreshing…" while a page sat
  waiting for a password. A refresh the user asks for now shows the login Frost
  is holding (showParkedLogin), the hotkey drops its own isWorking guard, and
  the tray says "Sign-in needed — waiting for you".
- The "Sign-in Needed" notification is gone. It existed because Frost stopped
  trying and that was invisible; the replacement login is the notice now, and a
  notification on top of it is the thing this whole change is meant to stop.
- Notification mode and manual approval cannot get through unattended at all, so
  they no longer burn a device code to find that out: Frost waits for the user
  and starts then, recording one run rather than one every half minute.
- retryDelayMsAfterError() splits into errorRetryDelayMs() (unchanged 1m→30m for
  failures with no login page involved) and nextLoginAttemptDelayMs(), with
  loginRetryAction() deciding each tick. AGENTS.md and the docs pages follow.

Verified with `npm run build`, `npm run lint`, and the headless harness
(dist/aws-sso.js against stubbed electron including powerMonitor idle time, a
faked SSO OIDC client and a fake clock): 53 checks over ten scenarios, plus the
real dist/tray.js rendering the held-login label. Eight simulated hours away
with a page that needs a human: 46-49 device codes, worst gap between attempts
10m30s (the code's life plus the poll that notices), nothing shown, nothing
notified, and the login page appears 10s after the first keypress. A slow
identity provider coming through mid-hold completes the refresh unattended.
Six hours away in default-browser mode: zero tabs, one tab when the user
returns. Notify mode, four hours away: zero device codes, one run.

Costs, deliberately accepted: a hidden login window and its 400ms approval scan
live continuously while a login is held rather than only during a refresh, and
the Activity page gains an entry per device code (~6/hour) while a machine is
away with a sign-in that needs a human.

Not checked here: real notifications, a real tray, and idle-time reporting on a
real desktop — the harness stubs all three. On a Linux session where
getSystemIdleTime() is unimplemented it reports 0, which reads as "somebody is
here", so those sessions get the visible login page rather than a held one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBe4cVzZUazesEcLLaktV4
@popen2
popen2 enabled auto-merge September 14, 2026 05:58
@popen2
popen2 merged commit 7522d2e into main Sep 14, 2026
9 checks passed
@popen2
popen2 deleted the claude/frost-refresh-notification-bug-bxkzh7 branch September 14, 2026 06:02
popen2 pushed a commit that referenced this pull request Sep 14, 2026
Notify mode is a promise not to put a login page in front of the user
unannounced, and it kept that promise by announcing every refresh:
notification, wait for the hotkey, then the login window. With automatic
approval that is backwards. Most refreshes now ask the user for nothing at
all, so notify mode was interrupting them once a working day about work
Frost was about to do without them.

The notification moves to the moment a refresh turns out to need a person.
Frost works through the approval off screen first; if the page asks for a
password, a one-time code or a security key, notify mode says so and waits
for the hotkey or a click before showing anything. A refresh the identity
provider session covers passes in silence. With automatic approval off
nothing changes: a login page is still about to open, so it is still
announced first.

That also makes notify mode able to finish with nobody at the machine,
which is what #92 needed canRefreshUnattended() for. It excluded notify
mode because the old flow waited for a hotkey nobody was there to press;
the new one only waits when the page actually needs a person, so an
overnight refresh in notify mode now renews credentials like any other
instead of stopping before it asks AWS for a device code.

Two details that go with it: the pending trigger is cancelled when the run
ends, or hasPendingAuth() keeps saying yes and swallows the next hotkey
press; and a refresh the user asks for while the notice is unanswered is
them saying yes, so it reaches the page Frost is already holding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XK1mi3rSPLzxu7XUhyC6a
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.

2 participants