@event4u/agent-switch 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `agent-switch auth-status [--json]` — how each profile's login stands, read
3
+ * from the provider's own credential store METADATA. Token values are parsed
4
+ * only to read expiry fields and are never copied into the result.
5
+ *
6
+ * States: `connected` (a refreshable login is stored), `expiring` (the stored
7
+ * login's refresh window ends within {@link EXPIRING_MS}), `sign-in-needed`
8
+ * (nothing usable stored, or the refresh window is over), `unknown` (the store
9
+ * cannot be read from here). Nothing is inferred from usage or network calls.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ export const EXPIRING_MS = 3 * 24 * 60 * 60_000;
14
+ /** Who keeps a Claude login fresh: the background service polls (and so
15
+ * refreshes) every enabled profile — except one sharing the default install's
16
+ * login, which agent-switch never refreshes. */
17
+ export function keptFreshByFor(provider, o, base) {
18
+ if (o.shared)
19
+ return "only you, by using it — it shares its login with the default install";
20
+ if (provider === "claude" && o.serviceInstalled)
21
+ return "the agent-switch service (Claude Code's own refresh)";
22
+ return base;
23
+ }
24
+ function iso(ms) {
25
+ return ms !== null && Number.isFinite(ms) ? new Date(ms).toISOString() : null;
26
+ }
27
+ function windowState(refreshEndsMs, now) {
28
+ if (refreshEndsMs === null)
29
+ return "connected";
30
+ if (refreshEndsMs <= now)
31
+ return "sign-in-needed";
32
+ return refreshEndsMs - now <= EXPIRING_MS ? "expiring" : "connected";
33
+ }
34
+ /**
35
+ * Claude Code credential (`{ claudeAiOauth: { refreshToken, expiresAt,
36
+ * refreshTokenExpiresAt? } }`). `expiresAt` there is the short-lived access
37
+ * token, which Claude Code refreshes itself — not a sign-in deadline, so it is
38
+ * not reported as one. `lastRefreshMs` is the store's mtime when it is a file.
39
+ */
40
+ export function claudeAuthStatus(name, raw, lastRefreshMs, now) {
41
+ const base = { provider: "claude", name, keptFreshBy: "claude (refreshes the login when it runs)" };
42
+ let oauth = null;
43
+ try {
44
+ oauth = raw ? JSON.parse(raw)?.claudeAiOauth : null;
45
+ }
46
+ catch {
47
+ oauth = null;
48
+ }
49
+ if (!raw) {
50
+ return { ...base, state: "sign-in-needed", expiresAt: null, lastRefreshAt: null, detail: "No stored login for this profile." };
51
+ }
52
+ if (oauth && oauth.refreshToken === "" && (oauth.accessToken === "" || oauth.accessToken == null)) {
53
+ return {
54
+ ...base,
55
+ state: "sign-in-needed",
56
+ expiresAt: null,
57
+ lastRefreshAt: iso(lastRefreshMs),
58
+ detail: "Claude Code cleared this login (signed out, or the refresh window ended). Sign in again.",
59
+ };
60
+ }
61
+ if (!oauth || typeof oauth.refreshToken !== "string" || !oauth.refreshToken) {
62
+ return { ...base, state: "unknown", expiresAt: null, lastRefreshAt: iso(lastRefreshMs), detail: "The stored login has an unrecognised shape." };
63
+ }
64
+ const refreshEnds = typeof oauth.refreshTokenExpiresAt === "number" ? oauth.refreshTokenExpiresAt : null;
65
+ return {
66
+ ...base,
67
+ state: windowState(refreshEnds, now),
68
+ expiresAt: iso(refreshEnds),
69
+ lastRefreshAt: iso(lastRefreshMs),
70
+ detail: refreshEnds === null
71
+ ? "A refreshable login is stored; Claude does not record when it ends, so a revoked login shows up only on next use."
72
+ : "A refreshable login is stored.",
73
+ };
74
+ }
75
+ /** Codex `auth.json` (`{ tokens: { refresh_token, … }, last_refresh, OPENAI_API_KEY? }`). */
76
+ /** A JWT's `exp` (epoch ms) from its payload — read without verifying, only to
77
+ * date it; null when it is not a JWT or carries no exp. */
78
+ function jwtExpMs(token) {
79
+ if (typeof token !== "string")
80
+ return null;
81
+ const payload = token.split(".")[1];
82
+ if (!payload)
83
+ return null;
84
+ try {
85
+ const exp = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))?.exp;
86
+ return typeof exp === "number" ? exp * 1000 : null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ export function codexAuthStatus(name, raw, now) {
93
+ const base = { provider: "codex", name, keptFreshBy: "codex (refreshes the login when it runs)" };
94
+ let auth = null;
95
+ try {
96
+ auth = raw ? JSON.parse(raw) : null;
97
+ }
98
+ catch {
99
+ auth = null;
100
+ }
101
+ if (!auth) {
102
+ return { ...base, state: raw ? "unknown" : "sign-in-needed", expiresAt: null, lastRefreshAt: null, detail: raw ? "auth.json is not readable JSON." : "No stored login for this profile." };
103
+ }
104
+ const last = typeof auth.last_refresh === "string" && !isNaN(Date.parse(auth.last_refresh)) ? new Date(auth.last_refresh).toISOString() : null;
105
+ if (typeof auth.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY) {
106
+ return { ...base, state: "connected", expiresAt: null, lastRefreshAt: last, keptFreshBy: "not needed (API key)", detail: "Signed in with an API key, billed per use." };
107
+ }
108
+ if (typeof auth?.tokens?.refresh_token !== "string" || !auth.tokens.refresh_token) {
109
+ return { ...base, state: "sign-in-needed", expiresAt: null, lastRefreshAt: last, detail: "No refreshable login stored." };
110
+ }
111
+ const accessEnds = jwtExpMs(auth.tokens.access_token);
112
+ if (accessEnds !== null && accessEnds <= now) {
113
+ return {
114
+ ...base,
115
+ state: "unknown",
116
+ expiresAt: null,
117
+ lastRefreshAt: last,
118
+ detail: `The access token ran out on ${new Date(accessEnds).toISOString().slice(0, 10)}; codex renews it the next time it runs on this account, if the login is still valid. Usage can't be read until then.`,
119
+ };
120
+ }
121
+ return {
122
+ ...base,
123
+ state: windowState(null, now),
124
+ expiresAt: null,
125
+ lastRefreshAt: last,
126
+ detail: "A refreshable login is stored; codex does not record when it ends.",
127
+ };
128
+ }
129
+ /** Antigravity keeps its login in a per-profile keychain agent-switch does not read here. */
130
+ export function antigravityAuthStatus(name, keychainExists) {
131
+ return {
132
+ provider: "antigravity",
133
+ name,
134
+ state: keychainExists ? "unknown" : "sign-in-needed",
135
+ expiresAt: null,
136
+ lastRefreshAt: null,
137
+ keptFreshBy: "agy (refreshes the login when it runs)",
138
+ detail: keychainExists ? "The login lives in the profile's keychain; its expiry is not readable from here." : "No profile keychain yet — sign in once with agy.",
139
+ };
140
+ }
141
+ export function fileMtimeMs(p) {
142
+ try {
143
+ return fs.statSync(p).mtimeMs;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ export function readText(p) {
150
+ try {
151
+ return fs.readFileSync(p, "utf8");
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ export function claudeCredentialFile(cfg) {
158
+ return path.join(cfg, ".credentials.json");
159
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Which account a start should use when several could: the one that would
3
+ * lose the most of its rest at its next reset. An account's "need" is the rest
4
+ * of its longest window divided by the hours until that window resets — the
5
+ * pace it would have to be used at to run dry exactly at the reset. Using the
6
+ * highest need first spreads the work so every account is spent by its reset
7
+ * instead of one running out while another resets unused.
8
+ *
9
+ * An account at its limit in any window (at or over its protection threshold)
10
+ * can't take work now and is never picked. Accounts whose needs are level
11
+ * (within {@link LEVEL_TOLERANCE}) are a tie the caller breaks — plain starts
12
+ * prefer the plain-start account, pools a member without a live session.
13
+ */
14
+ const HOUR_MS = 3_600_000;
15
+ /** A window without a reset time is assumed to reset a full week out. */
16
+ const UNKNOWN_RESET_H = 7 * 24;
17
+ /** Needs closer than this (relative) count as level. */
18
+ export const LEVEL_TOLERANCE = 0.05;
19
+ function longest(windows) {
20
+ const known = windows.filter((w) => typeof w.utilization === "number");
21
+ if (!known.length)
22
+ return null;
23
+ const withReset = known.filter((w) => w.resetsAt && !isNaN(Date.parse(w.resetsAt)));
24
+ if (withReset.length)
25
+ return withReset.reduce((a, b) => (Date.parse(b.resetsAt) > Date.parse(a.resetsAt) ? b : a));
26
+ return known.reduce((a, b) => (b.utilization > a.utilization ? b : a));
27
+ }
28
+ export function weigh(snapshot, now, thresholdPct) {
29
+ const capped = snapshot.windows.find((w) => typeof w.utilization === "number" && w.utilization >= thresholdPct);
30
+ if (capped)
31
+ return { usable: false, why: `at ${capped.utilization}% of its ${windowName(capped)} window (limit ${thresholdPct}%)` };
32
+ const w = longest(snapshot.windows);
33
+ if (!w)
34
+ return { usable: false, why: "usage unknown" };
35
+ const rest = Math.max(0, 100 - w.utilization);
36
+ const resetH = w.resetsAt && !isNaN(Date.parse(w.resetsAt)) ? Math.max(1, (Date.parse(w.resetsAt) - now) / HOUR_MS) : null;
37
+ // The fullest window breaks a level tie: equal room this week, but one is about
38
+ // to hit its 5h limit.
39
+ const peak = Math.max(...snapshot.windows.map((x) => (typeof x.utilization === "number" ? x.utilization : 0)));
40
+ return { usable: true, need: rest / (resetH ?? UNKNOWN_RESET_H), rest, resetH, window: windowName(w), key: w.key, peak };
41
+ }
42
+ const WINDOW_NAME = { five_hour: "5h", seven_day: "7d", monthly: "30d" };
43
+ /** Named by key: a window read back from the usage history carries its key as label. */
44
+ function windowName(w) {
45
+ return WINDOW_NAME[w.key] ?? w.label;
46
+ }
47
+ export function level(a, b) {
48
+ const hi = Math.max(a, b);
49
+ return hi === 0 || Math.abs(a - b) / hi <= LEVEL_TOLERANCE;
50
+ }
51
+ export function describe(name, w) {
52
+ const reset = w.resetH == null ? "reset time unknown" : w.resetH >= 48 ? `resets in ${Math.round(w.resetH / 24)} d` : `resets in ${Math.round(w.resetH)} h`;
53
+ const of = w.key === "seven_day" ? "the week" : `its ${w.window} window`;
54
+ return `${name}: ${Math.round(w.rest)}% of ${of} left, ${reset} (${w.need.toFixed(1)} %/h to use it up)`;
55
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Which browser a provider's sign-in opens in. Claude Code opens its OAuth page
3
+ * with `$BROWSER` when set (verified against CC 2.1.312: it spawns the value as
4
+ * ONE executable with the URL as its only argument — `open -a X` in the
5
+ * variable is not run). So on macOS the choice becomes a tiny wrapper script
6
+ * that hands the URL to that app; on Linux the browser binary itself is the
7
+ * executable. Windows is not offered: nothing here has verified an override.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ const MAC = [
12
+ { id: "safari", name: "Safari", app: "Safari" },
13
+ { id: "chrome", name: "Google Chrome", app: "Google Chrome" },
14
+ { id: "chrome-canary", name: "Google Chrome Canary", app: "Google Chrome Canary" },
15
+ { id: "firefox", name: "Firefox", app: "Firefox" },
16
+ { id: "firefox-dev", name: "Firefox Developer Edition", app: "Firefox Developer Edition" },
17
+ { id: "arc", name: "Arc", app: "Arc" },
18
+ { id: "brave", name: "Brave", app: "Brave Browser" },
19
+ { id: "edge", name: "Microsoft Edge", app: "Microsoft Edge" },
20
+ { id: "vivaldi", name: "Vivaldi", app: "Vivaldi" },
21
+ { id: "opera", name: "Opera", app: "Opera" },
22
+ { id: "chromium", name: "Chromium", app: "Chromium" },
23
+ { id: "zen", name: "Zen", app: "Zen" },
24
+ { id: "orion", name: "Orion", app: "Orion" },
25
+ { id: "dia", name: "Dia", app: "Dia" },
26
+ { id: "librewolf", name: "LibreWolf", app: "LibreWolf" },
27
+ ];
28
+ const LINUX = [
29
+ { id: "chrome", name: "Google Chrome", bins: ["google-chrome", "google-chrome-stable"] },
30
+ { id: "chromium", name: "Chromium", bins: ["chromium", "chromium-browser"] },
31
+ { id: "firefox", name: "Firefox", bins: ["firefox"] },
32
+ { id: "brave", name: "Brave", bins: ["brave-browser", "brave"] },
33
+ { id: "edge", name: "Microsoft Edge", bins: ["microsoft-edge", "microsoft-edge-stable"] },
34
+ { id: "vivaldi", name: "Vivaldi", bins: ["vivaldi", "vivaldi-stable"] },
35
+ { id: "librewolf", name: "LibreWolf", bins: ["librewolf"] },
36
+ ];
37
+ /** The .app bundle (macOS) or binary (Linux) for a browser id; null when it is
38
+ * unknown or not installed. */
39
+ export function locateBrowser(id, deps) {
40
+ if (deps.platform === "darwin") {
41
+ const b = MAC.find((x) => x.id === id);
42
+ if (!b)
43
+ return null;
44
+ // macOS paths are POSIX whatever platform runs this (tests run on Windows too).
45
+ for (const dir of ["/Applications", path.posix.join(deps.home, "Applications")]) {
46
+ const app = path.posix.join(dir, `${b.app}.app`);
47
+ if (deps.exists(app))
48
+ return app;
49
+ }
50
+ return null;
51
+ }
52
+ if (deps.platform === "linux") {
53
+ const b = LINUX.find((x) => x.id === id);
54
+ for (const bin of b?.bins ?? []) {
55
+ const p = deps.which(bin);
56
+ if (p)
57
+ return p;
58
+ }
59
+ return null;
60
+ }
61
+ return null;
62
+ }
63
+ export function listBrowsers(deps) {
64
+ const known = deps.platform === "darwin" ? MAC : deps.platform === "linux" ? LINUX : [];
65
+ return known.filter((b) => locateBrowser(b.id, deps) !== null).map(({ id, name }) => ({ id, name }));
66
+ }
67
+ /** POSIX wrapper for `$BROWSER` that opens its URL argument in one macOS app. */
68
+ export function browserWrapperScript(appPath) {
69
+ const quoted = `'${appPath.replace(/'/g, `'\\''`)}'`;
70
+ return `#!/bin/sh\nexec /usr/bin/open -a ${quoted} "$@"\n`;
71
+ }
72
+ function whichOnPath(binary) {
73
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
74
+ if (!dir)
75
+ continue;
76
+ const p = path.join(dir, binary);
77
+ try {
78
+ fs.accessSync(p, fs.constants.X_OK);
79
+ if (fs.statSync(p).isFile())
80
+ return p;
81
+ }
82
+ catch {
83
+ /* not here */
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ export function defaultBrowserDeps(home) {
89
+ return { platform: process.platform, home, exists: (p) => fs.existsSync(p), which: whichOnPath };
90
+ }
package/dist/daemon.js CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Background usage daemon — poller, cache, and (opt-in) threshold notifier.
3
3
  *
4
- * It polls Claude profiles for their OWN usage, appends history, detects
4
+ * It polls Claude and Codex profiles for their OWN usage — the active one, any
5
+ * with a live session, and the members of pools a folder rule targets (spaced
6
+ * to {@link POOL_POLL_INTERVAL_MS}, so `dir`'s pool pick has fresh numbers) —
7
+ * appends history, detects
5
8
  * active-profile threshold crossings, and writes `daemon-state.json` so
6
9
  * `status`/the GUI can read a fresh snapshot without hitting the API. When
7
10
  * `autoSwitch.enabled` (default OFF) it also watches every profile and, once the
@@ -10,20 +13,26 @@
10
13
  * suggestion is a convenience; the actual switch is always a user interaction
11
14
  * (see `AutoSwitchConfig`). The daemon never mutates the active profile.
12
15
  *
16
+ * Codex banked resets: when the active Codex profile is over the threshold and
17
+ * holds a reset credit, the daemon NOTIFIES that one is available (once per
18
+ * reset cycle). It never redeems one — that is an explicit user action
19
+ * (`agent-switch reset`, or the GUI's confirmed button). It also fires the
20
+ * per-profile protection notice (see `protection.ts`): a warning only, nothing
21
+ * is paused or switched.
22
+ *
13
23
  * The pure helpers (single-instance, poll-target selection, backoff, state
14
24
  * freshness, suggestion decision in `usage.ts`) are exported for unit testing;
15
25
  * `runDaemon` is the loop.
16
26
  */
17
27
  import * as fs from "node:fs";
18
28
  import * as path from "node:path";
19
- import { ROOT, activeFor, configDir, labelFor, listProfiles, readAutoSwitch, readBinaryPath } from "./profiles.js";
29
+ import { ROOT, activeFor, configDir, isDisabled, labelFor, listProfiles, readAutoSwitch, readBinaryPath, sharesDefaultLogin } from "./profiles.js";
20
30
  import { profileDir } from "./profiles.js";
21
31
  import { fetchUsage, liveSessionPids } from "./api.js";
22
- import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
32
+ import { defaultFreshenDeps, usageToken } from "./token-freshen.js";
23
33
  import { parseUsage, detectCrossings, pickSwitchTarget, maxUtilization } from "./usage.js";
24
34
  import { readCodexUsage } from "./codex-usage.js";
25
- import { redeemResetCredit } from "./codex-reset.js";
26
- import { recordHistorySample } from "./history.js";
35
+ import { LATEST_FILE, recordHistorySample, writeLatestSnapshot } from "./history.js";
27
36
  import { appendNotification, markOsNotified } from "./notifications.js";
28
37
  import { readOsNotifications } from "./profiles.js";
29
38
  import { osNotify } from "./os-notify.js";
@@ -31,6 +40,7 @@ import { listSessions, listCodexSessions, markLive } from "./sessions.js";
31
40
  import { readContext } from "./telemetry.js";
32
41
  import { readTelemetryConfig, detectContextCrossings, coalesce, } from "./notify.js";
33
42
  import { readEvents, eventFile } from "./hooks.js";
43
+ import { protectionNotice, readProtection } from "./protection.js";
34
44
  import { execFileSync } from "node:child_process";
35
45
  /** Append an event to the shared log and, when the operator opted into
36
46
  * daemon-side OS notifications, fire a desktop notification too — marking the
@@ -113,13 +123,23 @@ export function releaseSingleInstance(myPid, file = PIDFILE) {
113
123
  }
114
124
  }
115
125
  // ---------- poll discipline ----------
126
+ /** How often a profile that is polled ONLY because a pool rule targets it is
127
+ * refreshed. Below {@link FRESH_USAGE_MS} (10 min) so `dir` keeps seeing pool
128
+ * members as fresh, yet five times sparser than the active profile's cadence —
129
+ * idle pool members cost one usage read per profile every 5 minutes. */
130
+ export const POOL_POLL_INTERVAL_MS = 5 * 60_000;
116
131
  /**
117
- * Which Claude profiles to poll this cycle. Normally the active one plus any
118
- * with a live session — never a busy-poll of idle accounts. With `watchAll`
132
+ * Which profiles to poll this cycle. Normally the active one plus any with a
133
+ * live session — never a busy-poll of idle accounts. With `watchAll`
119
134
  * (auto-switch enabled) every profile is polled, since the switch decision
120
- * needs each candidate's headroom. `liveCheck` is injected for testability.
135
+ * needs each candidate's headroom. `pooled` profiles (every enabled account:
136
+ * plain starts and pools are balanced across them) are added too, but only once
137
+ * their last capture is at least
138
+ * {@link POOL_POLL_INTERVAL_MS} old — that is what keeps `dir`'s pool pick on
139
+ * fresh numbers without polling idle accounts every minute. `liveCheck` and
140
+ * `lastCapturedMs` are injected for testability.
121
141
  */
122
- export function selectPollTargets(claudeNames, active, liveCheck, watchAll = false) {
142
+ export function selectPollTargets(claudeNames, active, liveCheck, watchAll = false, pooled = [], lastCapturedMs = () => null, now = Date.now()) {
123
143
  if (watchAll)
124
144
  return [...claudeNames];
125
145
  const targets = new Set();
@@ -128,6 +148,13 @@ export function selectPollTargets(claudeNames, active, liveCheck, watchAll = fal
128
148
  for (const n of claudeNames)
129
149
  if (liveCheck(n))
130
150
  targets.add(n);
151
+ for (const n of pooled) {
152
+ if (!claudeNames.includes(n) || targets.has(n))
153
+ continue;
154
+ const last = lastCapturedMs(n);
155
+ if (last === null || now - last >= POOL_POLL_INTERVAL_MS)
156
+ targets.add(n);
157
+ }
131
158
  return [...targets];
132
159
  }
133
160
  /** Exponential backoff on consecutive failures, floored at the base interval
@@ -158,6 +185,28 @@ export function decideThresholdAction(active, polled, autoSwitch, isEligible = (
158
185
  const suggestedTarget = target && target !== active ? target : null;
159
186
  return { notify: suggestedTarget !== null, suggestedTarget };
160
187
  }
188
+ /**
189
+ * The "a banked Codex reset is available" notice (D3). Pure: returns the notice
190
+ * when the active profile is at/over the threshold, holds a reset credit, and
191
+ * this reset cycle was not announced yet — else null. Redeeming stays an
192
+ * explicit user action (`agent-switch reset`, the GUI's confirmed button).
193
+ */
194
+ export function codexResetNotice(active, snap, threshold, lastNotifiedCycle) {
195
+ if ((snap.resetCredits ?? 0) <= 0)
196
+ return null;
197
+ const stuck = snap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= threshold);
198
+ if (!stuck)
199
+ return null;
200
+ const cycle = stuck.resetsAt ?? "";
201
+ if (lastNotifiedCycle === cycle)
202
+ return null;
203
+ return {
204
+ cycle,
205
+ title: "Codex reset available",
206
+ message: `codex/${active} hit ≥${threshold}% and has ${snap.resetCredits} banked reset(s). ` +
207
+ `Nothing was redeemed — to use one: agent-switch reset ${active} --provider codex`,
208
+ };
209
+ }
161
210
  // ---------- context monitoring (own-session; Phase 3) ----------
162
211
  let _claudeVer;
163
212
  function claudeVer() {
@@ -246,19 +295,27 @@ async function snapshotFor(provider, name) {
246
295
  return readCodexUsage(configDir("codex", name));
247
296
  // An expired access token is the daemon's most common "usage stopped moving"
248
297
  // cause — a profile nobody is running never refreshes itself. Let Claude Code
249
- // refresh it first (cooldown-guarded, no-op while the token is still good).
250
- const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
298
+ // refresh it first (cooldown-guarded, no-op while the token is still good) —
299
+ // never for a profile sharing the default install's login.
300
+ const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name));
251
301
  if (!token)
252
302
  return null;
253
303
  const raw = await fetchUsage(token);
254
304
  return raw ? parseUsage(raw) : null;
255
305
  }
256
- async function pollProvider(provider, state, thresholds, redeemed, log) {
257
- const names = listProfiles(provider);
306
+ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
307
+ const names = listProfiles(provider).filter((n) => !isDisabled(provider, n));
258
308
  const active = activeFor(provider);
259
309
  const autoSwitch = readAutoSwitch(provider);
260
310
  const isLive = (n) => (provider === "claude" ? liveSessionPids(configDir("claude", n)).length > 0 : false);
261
- const targets = selectPollTargets(names, active, isLive, autoSwitch.enabled);
311
+ // Every start without a folder rule is balanced across all enabled accounts
312
+ // (dir-resolve.ts), so each one needs fresh numbers — at the pool cadence.
313
+ const pooled = names;
314
+ const lastCaptured = (n) => {
315
+ const t = Date.parse(state.profiles[`${provider}/${n}`]?.capturedAt ?? "");
316
+ return isNaN(t) ? null : t;
317
+ };
318
+ const targets = selectPollTargets(names, active, isLive, autoSwitch.enabled, pooled, lastCaptured);
262
319
  let failures = 0;
263
320
  const polled = [];
264
321
  state.notifiedFetchFail = state.notifiedFetchFail ?? {};
@@ -286,7 +343,17 @@ async function pollProvider(provider, state, thresholds, redeemed, log) {
286
343
  }
287
344
  state.profiles[key] = snapshot;
288
345
  polled.push({ name, snapshot });
346
+ // Session protection (per profile, default on at 98 %): a warning only.
347
+ const pNotice = protectionNotice(key, snapshot, readProtection(provider, name), state.protectionNotified?.[key]);
348
+ if (pNotice) {
349
+ state.protectionNotified = { ...(state.protectionNotified ?? {}), [key]: pNotice.cycle };
350
+ log(`protection: ${pNotice.message}`);
351
+ // "info", not "warning": the GUI mutes warnings by default (fetch-failure
352
+ // noise), and a default-on protection notice must actually be seen.
353
+ notify({ kind: "info", title: pNotice.title, message: pNotice.message });
354
+ }
289
355
  recordHistorySample(path.join(profileDir(provider, name), "usage-history.json"), snapshot);
356
+ writeLatestSnapshot(path.join(profileDir(provider, name), LATEST_FILE), snapshot);
290
357
  if (name === active) {
291
358
  const prev = thresholds.get(`${provider}/${name}`) ?? {};
292
359
  const { crossings, state: next } = detectCrossings(snapshot, prev);
@@ -311,28 +378,15 @@ async function pollProvider(provider, state, thresholds, redeemed, log) {
311
378
  const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
312
379
  if (activeMax === null || activeMax < autoSwitch.threshold)
313
380
  return failures; // still has headroom
314
- // Codex only (banked resets are a Codex feature): redeem a reset and STAY, but
315
- // AT MOST ONCE per reset cycle. We record the window's resets_at BEFORE calling,
316
- // so a buggy reset that doesn't actually clear usage can never loop and burn the
317
- // whole balance — it falls through to a switch SUGGESTION on the same poll's
318
- // next-cycle. Redeeming still stays on the active profile.
381
+ // Codex only (banked resets are a Codex feature): a reset credit is scarce and
382
+ // redeeming it is the user's call (D3) — the daemon only says one is there,
383
+ // once per reset cycle, and never calls the redeem endpoint itself.
319
384
  if (provider === "codex" && activeSnap) {
320
- const stuck = activeSnap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= autoSwitch.threshold);
321
- const cycle = stuck?.resetsAt ?? "";
322
- const guardKey = `codex/${active}`;
323
- if ((activeSnap.resetCredits ?? 0) > 0 && redeemed.get(guardKey) !== cycle) {
324
- redeemed.set(guardKey, cycle);
325
- const r = await redeemResetCredit(configDir("codex", active));
326
- if (r.ok) {
327
- log(`auto-switch: codex/${active} over ≥${autoSwitch.threshold}% → redeemed a reset (windows_reset=${r.windowsReset ?? "?"}); staying on ${active}`);
328
- notify({
329
- kind: "success",
330
- title: "Codex reset redeemed",
331
- message: `codex/${active} hit ≥${autoSwitch.threshold}% — redeemed a banked reset and stayed on ${active}.`,
332
- });
333
- return failures;
334
- }
335
- log(`auto-switch: codex/${active} reset redeem failed (${r.reason ?? "?"}) → falling back to a switch suggestion`);
385
+ const notice = codexResetNotice(active, activeSnap, autoSwitch.threshold, notifiedReset.get(`codex/${active}`));
386
+ if (notice) {
387
+ notifiedReset.set(`codex/${active}`, notice.cycle);
388
+ log(`threshold: codex/${active} over ≥${autoSwitch.threshold}% with a banked reset available → notifying (no automatic redeem)`);
389
+ notify({ kind: "info", title: notice.title, message: notice.message });
336
390
  }
337
391
  }
338
392
  // Tag filter: only accounts carrying the configured label are eligible switch
@@ -361,10 +415,10 @@ async function pollProvider(provider, state, thresholds, redeemed, log) {
361
415
  }
362
416
  return failures;
363
417
  }
364
- async function pollOnce(state, thresholds, redeemed, log) {
418
+ async function pollOnce(state, thresholds, notifiedReset, log) {
365
419
  let failures = 0;
366
420
  for (const provider of AUTO_PROVIDERS) {
367
- failures += await pollProvider(provider, state, thresholds, redeemed, log);
421
+ failures += await pollProvider(provider, state, thresholds, notifiedReset, log);
368
422
  }
369
423
  state.lastPoll = new Date().toISOString();
370
424
  state.lastError = failures > 0 ? `${failures} profile(s) failed to poll` : null;
@@ -396,16 +450,15 @@ export async function runDaemon(opts = {}) {
396
450
  // Load persisted usage-window fired state so crossings do NOT re-fire after a
397
451
  // daemon restart within the same window cycle (the pre-existing in-memory gap).
398
452
  const thresholds = new Map(Object.entries(state.usageThresholds ?? {}));
399
- // Anti-loop guard for the Codex reset redemption: `<provider>/<name>` → the
400
- // window resets_at we last redeemed for, so we never redeem the same stuck
401
- // cycle twice.
402
- const redeemed = new Map();
453
+ // `<provider>/<name>` → the window resets_at we last announced a banked reset
454
+ // for, so the "reset available" notice fires once per cycle, not every poll.
455
+ const notifiedReset = new Map();
403
456
  let consecutiveFailures = 0;
404
457
  let cycles = 0;
405
458
  log(`started (pid ${process.pid}, base interval ${baseInterval}ms).`);
406
459
  while (running) {
407
460
  try {
408
- const failures = await pollOnce(state, thresholds, redeemed, log);
461
+ const failures = await pollOnce(state, thresholds, notifiedReset, log);
409
462
  consecutiveFailures = failures > 0 ? consecutiveFailures + 1 : 0;
410
463
  }
411
464
  catch (err) {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Claude Code sessions run from Claude Desktop.
3
+ *
4
+ * Desktop keeps one metadata file per session under its data dir, filed by
5
+ * the signed-in account: `<data>/<kind>/<accountUuid>/<orgUuid>/local_<id>.json`
6
+ * with the `cliSessionId` that names the transcript. Where that transcript
7
+ * lives depends on the kind (measured on Claude Desktop with Claude Code
8
+ * 2.1.295):
9
+ *
10
+ * - `claude-code-sessions` (the Code tab): in the DEFAULT `~/.claude/projects/`
11
+ * — even when the Desktop instance belongs to an agent-switch profile.
12
+ * - `local-agent-mode-sessions`: in the session's own `local_<id>/.claude/`.
13
+ *
14
+ * The transcript itself names no account, so a session is attributed through
15
+ * the account folder it is filed under, matched against each profile's
16
+ * `oauthAccount.accountUuid`. A profile's Desktop instance holding sessions of
17
+ * another account is signed in with that account — the caller warns about it.
18
+ */
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import { readSessionHeader } from "./sessions.js";
22
+ /** Written within this long → shown as running (Desktop leaves no pid to check). */
23
+ export const DESKTOP_LIVE_MS = 2 * 60_000;
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
25
+ const KINDS = ["claude-code-sessions", "local-agent-mode-sessions"];
26
+ function dirs(p) {
27
+ try {
28
+ return fs
29
+ .readdirSync(p, { withFileTypes: true })
30
+ .filter((e) => e.isDirectory())
31
+ .map((e) => e.name)
32
+ .sort();
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ function files(p) {
39
+ try {
40
+ return fs.readdirSync(p).filter((f) => f.startsWith("local_") && f.endsWith(".json"));
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
46
+ /** `<projects>/<any encoded cwd>/<id>.jsonl`, or null. */
47
+ function findTranscript(projects, id) {
48
+ for (const d of dirs(projects)) {
49
+ const f = path.join(projects, d, `${id}.jsonl`);
50
+ if (fs.existsSync(f))
51
+ return f;
52
+ }
53
+ return null;
54
+ }
55
+ function readMeta(file) {
56
+ try {
57
+ const d = JSON.parse(fs.readFileSync(file, "utf8"));
58
+ return d && typeof d === "object" ? d : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ export function scanDesktop(opts) {
65
+ const rows = [];
66
+ const instances = [];
67
+ for (const inst of opts.instances) {
68
+ const seen = new Map();
69
+ for (const kind of KINDS) {
70
+ const base = path.join(inst.dir, kind);
71
+ for (const account of dirs(base).filter((d) => UUID_RE.test(d))) {
72
+ const profile = opts.profileOf(account);
73
+ seen.set(account, profile);
74
+ for (const org of dirs(path.join(base, account))) {
75
+ const folder = path.join(base, account, org);
76
+ for (const f of files(folder)) {
77
+ const m = readMeta(path.join(folder, f));
78
+ const id = typeof m?.cliSessionId === "string" && UUID_RE.test(m.cliSessionId) ? m.cliSessionId : null;
79
+ if (!m || !id || m.isArchived === true || !profile)
80
+ continue;
81
+ const transcript = kind === "claude-code-sessions"
82
+ ? findTranscript(path.join(opts.claudeHome, "projects"), id)
83
+ : findTranscript(path.join(folder, f.slice(0, -".json".length), ".claude", "projects"), id);
84
+ let st = null;
85
+ try {
86
+ st = transcript ? fs.statSync(transcript) : null;
87
+ }
88
+ catch {
89
+ st = null;
90
+ }
91
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
92
+ const mtimeMs = st?.mtimeMs ?? num(m.lastActivityAt) ?? num(m.createdAt) ?? 0;
93
+ const header = transcript ? readSessionHeader(transcript) : { cwd: null, summary: null };
94
+ rows.push({
95
+ sessionId: id,
96
+ projectDir: transcript ? path.basename(path.dirname(transcript)) : "",
97
+ cwd: typeof m.cwd === "string" ? m.cwd : header.cwd,
98
+ summary: typeof m.title === "string" && m.title ? m.title : header.summary,
99
+ mtimeMs,
100
+ birthtimeMs: num(m.createdAt) ?? st?.birthtimeMs ?? mtimeMs,
101
+ live: !!st && opts.now - st.mtimeMs <= DESKTOP_LIVE_MS,
102
+ ...(transcript ? { file: transcript } : {}),
103
+ profile,
104
+ surface: "desktop-app",
105
+ });
106
+ }
107
+ }
108
+ }
109
+ }
110
+ const accounts = [...seen.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([accountUuid, profile]) => ({ accountUuid, profile }));
111
+ instances.push({ ...inst, accounts, mismatch: inst.profile !== null && accounts.some((a) => a.profile !== inst.profile) });
112
+ }
113
+ rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
114
+ return { rows, instances };
115
+ }