@event4u/agent-switch 2.3.0 → 2.3.2
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.
- package/dist/daemon.js +118 -34
- package/dist/index.js +12 -10
- package/dist/os-notify.js +3 -9
- package/dist/providers.js +23 -12
- package/dist/telemetry.js +4 -0
- package/dist/token-freshen.js +17 -6
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -24,11 +24,12 @@
|
|
|
24
24
|
* freshness, suggestion decision in `usage.ts`) are exported for unit testing;
|
|
25
25
|
* `runDaemon` is the loop.
|
|
26
26
|
*/
|
|
27
|
+
import * as crypto from "node:crypto";
|
|
27
28
|
import * as fs from "node:fs";
|
|
28
29
|
import * as path from "node:path";
|
|
29
30
|
import { ROOT, activeFor, configDir, isDisabled, labelFor, listProfiles, readAutoSwitch, readBinaryPath, sharesDefaultLogin } from "./profiles.js";
|
|
30
31
|
import { profileDir } from "./profiles.js";
|
|
31
|
-
import { fetchUsage, liveSessionPids } from "./api.js";
|
|
32
|
+
import { fetchUsage, liveSessionPids, readProfileCredential } from "./api.js";
|
|
32
33
|
import { defaultFreshenDeps, usageToken } from "./token-freshen.js";
|
|
33
34
|
import { parseUsage, detectCrossings, pickSwitchTarget, maxUtilization } from "./usage.js";
|
|
34
35
|
import { readCodexUsage } from "./codex-usage.js";
|
|
@@ -46,12 +47,29 @@ import { execFileSync } from "node:child_process";
|
|
|
46
47
|
* daemon-side OS notifications, fire a desktop notification too — marking the
|
|
47
48
|
* record so the GUI does not show it a second time. Deduped events (append
|
|
48
49
|
* returns null) never OS-notify. */
|
|
49
|
-
function notify(event) {
|
|
50
|
+
function notify(event, opts = {}) {
|
|
50
51
|
const created = appendNotification(event);
|
|
51
|
-
if (created && readOsNotifications() && osNotify(event.title, event.message)) {
|
|
52
|
+
if (created && opts.os !== false && readOsNotifications() && osNotify(event.title, event.message)) {
|
|
52
53
|
markOsNotified(created.id);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
/** How long reads must fail before it counts as an outage. A single failed read
|
|
57
|
+
* is routine — a rate-limited endpoint answers again a minute later — and the
|
|
58
|
+
* last known values keep being served meanwhile. */
|
|
59
|
+
export const FETCH_OUTAGE_MS = 15 * 60_000;
|
|
60
|
+
/** The notice for a profile whose reads have failed since `sinceMs`, or null
|
|
61
|
+
* while it is still a blip. Kept off the desktop: a fetch failure is a
|
|
62
|
+
* diagnostic for the app's log, not something to interrupt anyone for. */
|
|
63
|
+
export function fetchFailureNotice(key, sinceMs, now) {
|
|
64
|
+
if (now - sinceMs < FETCH_OUTAGE_MS)
|
|
65
|
+
return null;
|
|
66
|
+
return {
|
|
67
|
+
kind: "warning",
|
|
68
|
+
title: "Usage fetch failed",
|
|
69
|
+
message: `Could not fetch usage limits for ${key} for ${Math.round((now - sinceMs) / 60_000)} min. Keeping the last known values.`,
|
|
70
|
+
os: false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
55
73
|
// Providers the daemon may suggest a switch for on threshold (they expose a
|
|
56
74
|
// usage readout to base a headroom suggestion on). Antigravity has none.
|
|
57
75
|
const AUTO_PROVIDERS = ["claude", "codex"];
|
|
@@ -72,14 +90,6 @@ export function writeDaemonState(state, file = STATE_FILE) {
|
|
|
72
90
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
73
91
|
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
74
92
|
}
|
|
75
|
-
/** True when the state's lastPoll is within maxAgeMs of `now` — the CLI/GUI use
|
|
76
|
-
* this to decide whether to trust the cache instead of hitting the API. */
|
|
77
|
-
export function isFresh(state, maxAgeMs, now = Date.now()) {
|
|
78
|
-
if (!state?.lastPoll)
|
|
79
|
-
return false;
|
|
80
|
-
const t = Date.parse(state.lastPoll);
|
|
81
|
-
return !isNaN(t) && now - t <= maxAgeMs;
|
|
82
|
-
}
|
|
83
93
|
// ---------- single instance (pidfile) ----------
|
|
84
94
|
export function processAlive(pid) {
|
|
85
95
|
try {
|
|
@@ -165,6 +175,54 @@ export function nextIntervalMs(baseMs, consecutiveFailures, capMs = MAX_BACKOFF_
|
|
|
165
175
|
return base;
|
|
166
176
|
return Math.min(base * 2 ** consecutiveFailures, capMs);
|
|
167
177
|
}
|
|
178
|
+
/** The profile's backoff after a read — undefined once a read succeeds. */
|
|
179
|
+
export function nextProfileBackoff(prev, ok, baseMs, now, stamp) {
|
|
180
|
+
if (ok)
|
|
181
|
+
return undefined;
|
|
182
|
+
const failures = (prev?.failures ?? 0) + 1;
|
|
183
|
+
return { failures, nextAt: now + nextIntervalMs(baseMs, failures), ...(stamp !== undefined ? { stamp } : {}) };
|
|
184
|
+
}
|
|
185
|
+
export function backoffBlocks(b, now, stamp) {
|
|
186
|
+
if (b === undefined)
|
|
187
|
+
return false;
|
|
188
|
+
if (b.stamp !== undefined && stamp !== b.stamp)
|
|
189
|
+
return false;
|
|
190
|
+
return now < b.nextAt;
|
|
191
|
+
}
|
|
192
|
+
/** A fingerprint of a stored login, so a change can be noticed without the
|
|
193
|
+
* service holding on to the credential. */
|
|
194
|
+
export function loginStamp(raw) {
|
|
195
|
+
return raw === null ? null : crypto.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 16);
|
|
196
|
+
}
|
|
197
|
+
/** The targets a cycle reads: those not held back by their own backoff. The
|
|
198
|
+
* login is read only for a profile that has a backoff entry. */
|
|
199
|
+
export function dueTargets(provider, targets, backoff, now, stampOf) {
|
|
200
|
+
return targets.filter((n) => {
|
|
201
|
+
const b = backoff.get(`${provider}/${n}`);
|
|
202
|
+
return !b || !backoffBlocks(b, now, b.stamp !== undefined ? stampOf(n) : undefined);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** The loop-wide failure count after a cycle: it grows only when every read
|
|
206
|
+
* failed (offline, API down) — single failures are handled by
|
|
207
|
+
* {@link nextProfileBackoff}. A cycle that read nothing leaves it unchanged. */
|
|
208
|
+
export function nextConsecutiveFailures(prev, failures, attempted) {
|
|
209
|
+
if (attempted === 0)
|
|
210
|
+
return prev;
|
|
211
|
+
return failures >= attempted ? prev + 1 : 0;
|
|
212
|
+
}
|
|
213
|
+
/** How old a cached snapshot may be and still be served instead of a live read:
|
|
214
|
+
* the daemon's slowest cadence (an idle account, or its current interval when
|
|
215
|
+
* that is longer) plus one loop wake. A healthy daemon answers from cache; a
|
|
216
|
+
* profile whose reads keep failing ages out and is read live. */
|
|
217
|
+
export function cacheMaxAgeMs(pollIntervalMs) {
|
|
218
|
+
return Math.max(POOL_POLL_INTERVAL_MS, pollIntervalMs ?? 0) + MIN_INTERVAL_MS;
|
|
219
|
+
}
|
|
220
|
+
export function snapshotFresh(snap, now = Date.now(), maxAgeMs = cacheMaxAgeMs(undefined)) {
|
|
221
|
+
if (!snap)
|
|
222
|
+
return false;
|
|
223
|
+
const t = Date.parse(snap.capturedAt);
|
|
224
|
+
return !isNaN(t) && now - t <= maxAgeMs;
|
|
225
|
+
}
|
|
168
226
|
/**
|
|
169
227
|
* Compliance-locked threshold decision (Council follow-up). Given the active
|
|
170
228
|
* profile and the same-provider candidates polled this cycle, decide whether to
|
|
@@ -290,20 +348,38 @@ export function monitorContext(provider, name, state, log) {
|
|
|
290
348
|
* the number of failures this cycle (for backoff). */
|
|
291
349
|
/** One profile's own usage snapshot: Claude via its OAuth endpoint, Codex via
|
|
292
350
|
* the live `wham/usage` read. null when unreadable (counts as a poll failure). */
|
|
293
|
-
async function snapshotFor(provider, name) {
|
|
351
|
+
async function snapshotFor(provider, name, log) {
|
|
294
352
|
if (provider === "codex")
|
|
295
353
|
return readCodexUsage(configDir("codex", name));
|
|
296
354
|
// An expired access token is the daemon's most common "usage stopped moving"
|
|
297
355
|
// cause — a profile nobody is running never refreshes itself. Let Claude Code
|
|
298
356
|
// refresh it first (cooldown-guarded, no-op while the token is still good) —
|
|
299
357
|
// never for a profile sharing the default install's login.
|
|
300
|
-
const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name))
|
|
358
|
+
const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name), (outcome) => {
|
|
359
|
+
if (outcome === "unavailable")
|
|
360
|
+
log(`freshen: claude/${name} — could not start \`claude doctor\` (not found on this service's PATH; link it in the app)`);
|
|
361
|
+
else if (outcome === "still-expired")
|
|
362
|
+
log(`freshen: claude/${name} — Claude Code could not refresh the login (sign in again)`);
|
|
363
|
+
});
|
|
301
364
|
if (!token)
|
|
302
365
|
return null;
|
|
303
366
|
const raw = await fetchUsage(token);
|
|
304
367
|
return raw ? parseUsage(raw) : null;
|
|
305
368
|
}
|
|
306
|
-
|
|
369
|
+
/** The stored login behind a profile, fingerprinted: Claude's credential
|
|
370
|
+
* store, Codex's auth.json. Read after a failure, so a refresh the failed
|
|
371
|
+
* read already triggered counts as the baseline, not as a change. */
|
|
372
|
+
function loginStampFor(provider, name) {
|
|
373
|
+
if (provider === "claude")
|
|
374
|
+
return loginStamp(readProfileCredential(configDir("claude", name)));
|
|
375
|
+
try {
|
|
376
|
+
return loginStamp(fs.readFileSync(path.join(configDir(provider, name), "auth.json"), "utf8"));
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function pollProvider(provider, state, thresholds, notifiedReset, backoff, baseMs, log) {
|
|
307
383
|
const names = listProfiles(provider).filter((n) => !isDisabled(provider, n));
|
|
308
384
|
const active = activeFor(provider);
|
|
309
385
|
const autoSwitch = readAutoSwitch(provider);
|
|
@@ -315,28 +391,32 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
|
|
|
315
391
|
const t = Date.parse(state.profiles[`${provider}/${n}`]?.capturedAt ?? "");
|
|
316
392
|
return isNaN(t) ? null : t;
|
|
317
393
|
};
|
|
318
|
-
const targets = selectPollTargets(names, active, isLive, autoSwitch.enabled, pooled, lastCaptured);
|
|
394
|
+
const targets = dueTargets(provider, selectPollTargets(names, active, isLive, autoSwitch.enabled, pooled, lastCaptured), backoff, Date.now(), (n) => loginStampFor(provider, n));
|
|
319
395
|
let failures = 0;
|
|
320
396
|
const polled = [];
|
|
321
397
|
state.notifiedFetchFail = state.notifiedFetchFail ?? {};
|
|
398
|
+
state.fetchFailingSince = state.fetchFailingSince ?? {};
|
|
322
399
|
for (const name of targets) {
|
|
323
400
|
const key = `${provider}/${name}`;
|
|
324
|
-
const snapshot = await snapshotFor(provider, name);
|
|
401
|
+
const snapshot = await snapshotFor(provider, name, log);
|
|
402
|
+
const nextBackoff = snapshot ? undefined : nextProfileBackoff(backoff.get(key), false, baseMs, Date.now(), loginStampFor(provider, name));
|
|
403
|
+
if (nextBackoff)
|
|
404
|
+
backoff.set(key, nextBackoff);
|
|
405
|
+
else
|
|
406
|
+
backoff.delete(key);
|
|
325
407
|
if (!snapshot) {
|
|
326
408
|
failures++;
|
|
327
409
|
// Fail-safe: the last-known snapshot in state.profiles[key] is left intact
|
|
328
|
-
// (never overwritten with null)
|
|
329
|
-
|
|
330
|
-
|
|
410
|
+
// (never overwritten with null). Only an outage is reported, once.
|
|
411
|
+
const since = state.fetchFailingSince[key] ?? (state.fetchFailingSince[key] = new Date().toISOString());
|
|
412
|
+
const notice = state.notifiedFetchFail[key] ? null : fetchFailureNotice(key, Date.parse(since), Date.now());
|
|
413
|
+
if (notice) {
|
|
331
414
|
state.notifiedFetchFail[key] = true;
|
|
332
|
-
notify({
|
|
333
|
-
kind: "warning",
|
|
334
|
-
title: "Usage fetch failed",
|
|
335
|
-
message: `Could not fetch usage limits for ${provider}/${name}. Keeping the last known values.`,
|
|
336
|
-
});
|
|
415
|
+
notify(notice, { os: notice.os });
|
|
337
416
|
}
|
|
338
417
|
continue;
|
|
339
418
|
}
|
|
419
|
+
delete state.fetchFailingSince[key];
|
|
340
420
|
if (state.notifiedFetchFail[key]) {
|
|
341
421
|
delete state.notifiedFetchFail[key];
|
|
342
422
|
log(`recovered: ${provider}/${name} usage fetch succeeded again`);
|
|
@@ -372,12 +452,13 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
|
|
|
372
452
|
// profile even when the usage API poll failed (expired token, offline).
|
|
373
453
|
if (active)
|
|
374
454
|
monitorContext(provider, active, state, log);
|
|
455
|
+
const result = { failures, attempted: targets.length };
|
|
375
456
|
if (!autoSwitch.enabled || !active)
|
|
376
|
-
return
|
|
457
|
+
return result;
|
|
377
458
|
const activeSnap = polled.find((p) => p.name === active)?.snapshot;
|
|
378
459
|
const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
|
|
379
460
|
if (activeMax === null || activeMax < autoSwitch.threshold)
|
|
380
|
-
return
|
|
461
|
+
return result; // still has headroom
|
|
381
462
|
// Codex only (banked resets are a Codex feature): a reset credit is scarce and
|
|
382
463
|
// redeeming it is the user's call (D3) — the daemon only says one is there,
|
|
383
464
|
// once per reset cycle, and never calls the redeem endpoint itself.
|
|
@@ -413,16 +494,18 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
|
|
|
413
494
|
message: `${provider}/${active} hit ≥${autoSwitch.threshold}% — suggested profile: ${provider}/${target}. ${how}`,
|
|
414
495
|
});
|
|
415
496
|
}
|
|
416
|
-
return
|
|
497
|
+
return result;
|
|
417
498
|
}
|
|
418
|
-
async function pollOnce(state, thresholds, notifiedReset, log) {
|
|
419
|
-
|
|
499
|
+
async function pollOnce(state, thresholds, notifiedReset, backoff, baseMs, log) {
|
|
500
|
+
const total = { failures: 0, attempted: 0 };
|
|
420
501
|
for (const provider of AUTO_PROVIDERS) {
|
|
421
|
-
|
|
502
|
+
const r = await pollProvider(provider, state, thresholds, notifiedReset, backoff, baseMs, log);
|
|
503
|
+
total.failures += r.failures;
|
|
504
|
+
total.attempted += r.attempted;
|
|
422
505
|
}
|
|
423
506
|
state.lastPoll = new Date().toISOString();
|
|
424
|
-
state.lastError = failures > 0 ? `${failures} profile(s) failed to poll` : null;
|
|
425
|
-
return
|
|
507
|
+
state.lastError = total.failures > 0 ? `${total.failures} profile(s) failed to poll` : null;
|
|
508
|
+
return total;
|
|
426
509
|
}
|
|
427
510
|
/** Run the daemon loop. Single-instance; SIGTERM-clean. */
|
|
428
511
|
export async function runDaemon(opts = {}) {
|
|
@@ -453,13 +536,14 @@ export async function runDaemon(opts = {}) {
|
|
|
453
536
|
// `<provider>/<name>` → the window resets_at we last announced a banked reset
|
|
454
537
|
// for, so the "reset available" notice fires once per cycle, not every poll.
|
|
455
538
|
const notifiedReset = new Map();
|
|
539
|
+
const backoff = new Map();
|
|
456
540
|
let consecutiveFailures = 0;
|
|
457
541
|
let cycles = 0;
|
|
458
542
|
log(`started (pid ${process.pid}, base interval ${baseInterval}ms).`);
|
|
459
543
|
while (running) {
|
|
460
544
|
try {
|
|
461
|
-
const failures = await pollOnce(state, thresholds, notifiedReset, log);
|
|
462
|
-
consecutiveFailures =
|
|
545
|
+
const { failures, attempted } = await pollOnce(state, thresholds, notifiedReset, backoff, baseInterval, log);
|
|
546
|
+
consecutiveFailures = nextConsecutiveFailures(consecutiveFailures, failures, attempted);
|
|
463
547
|
}
|
|
464
548
|
catch (err) {
|
|
465
549
|
consecutiveFailures++;
|
package/dist/index.js
CHANGED
|
@@ -47,7 +47,7 @@ import { formatSnapshot, parseUsage } from "./usage.js";
|
|
|
47
47
|
import { readCodexUsage } from "./codex-usage.js";
|
|
48
48
|
import { redeemResetCredit } from "./codex-reset.js";
|
|
49
49
|
import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, detectRunningClaudeProfile, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, openQuestion, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
|
|
50
|
-
import {
|
|
50
|
+
import { cacheMaxAgeMs, snapshotFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
|
|
51
51
|
import { cmdService, serviceAfterUpdate, serviceInstall, serviceInstallFromDisk, serviceTarget, serviceUninstall } from "./service.js";
|
|
52
52
|
import { fileURLToPath } from "node:url";
|
|
53
53
|
import { readContext, turnInFlight } from "./telemetry.js";
|
|
@@ -456,14 +456,13 @@ function cmdWhoami(providerId, name) {
|
|
|
456
456
|
* credential is unreadable or the API shape is unknown. Codex/Antigravity have no
|
|
457
457
|
* usage readout (verified), so this is Claude-only. */
|
|
458
458
|
async function claudeSnapshot(name) {
|
|
459
|
-
// Prefer the daemon's cache
|
|
460
|
-
// daemon is a cache, and the CLI works with or without it.
|
|
459
|
+
// Prefer the daemon's cache while THIS profile's snapshot is fresh — the
|
|
460
|
+
// daemon is a cache, and the CLI works with or without it. The age of the
|
|
461
|
+
// last poll says nothing about a profile whose reads have been failing.
|
|
461
462
|
const state = readDaemonState();
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return cached;
|
|
466
|
-
}
|
|
463
|
+
const cached = state?.profiles?.[`claude/${name}`];
|
|
464
|
+
if (snapshotFresh(cached, Date.now(), cacheMaxAgeMs(state?.pollIntervalMs)))
|
|
465
|
+
return cached;
|
|
467
466
|
const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name));
|
|
468
467
|
if (!token)
|
|
469
468
|
return null;
|
|
@@ -2058,8 +2057,11 @@ function cmdOsNotify(sub, json = false) {
|
|
|
2058
2057
|
const enabled = readOsNotifications();
|
|
2059
2058
|
if (json)
|
|
2060
2059
|
console.log(JSON.stringify({ enabled }));
|
|
2061
|
-
else
|
|
2060
|
+
else {
|
|
2062
2061
|
console.log(`Daemon OS notifications ${enabled ? "ON" : "OFF"}.`);
|
|
2062
|
+
if (enabled && process.platform === "darwin")
|
|
2063
|
+
console.log("On macOS the daemon shows none itself — the Agent Switch app shows them while it runs.");
|
|
2064
|
+
}
|
|
2063
2065
|
}
|
|
2064
2066
|
/** `agent-switch notifications [clear] [--json]` — list the recent
|
|
2065
2067
|
* notifications (newest first) or clear the log. */
|
|
@@ -2521,7 +2523,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
|
|
|
2521
2523
|
agent-switch service run|start|stop|status|install|uninstall background usage daemon
|
|
2522
2524
|
agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
|
|
2523
2525
|
agent-switch notify --kind K --title T --message M [--json] record a notification event
|
|
2524
|
-
agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
|
|
2526
|
+
agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications, Linux/Windows (default off; macOS: the app shows them)
|
|
2525
2527
|
agent-switch uninstall [--force] remove all agent-switch data + daemon
|
|
2526
2528
|
agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
|
|
2527
2529
|
agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
|
package/dist/os-notify.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Best-effort OS desktop notifications for the background daemon, so an
|
|
3
3
|
* auto-switch is visible even when the GUI is closed. Per-platform native
|
|
4
4
|
* command, no external dependency:
|
|
5
|
-
* - macOS → `osascript
|
|
5
|
+
* - macOS → nothing: an `osascript` notification is attributed to Script
|
|
6
|
+
* Editor. The Agent Switch app shows the notice itself when it
|
|
7
|
+
* runs (records not marked OS-notified), otherwise no one does.
|
|
6
8
|
* - Linux → `notify-send <title> <body>`
|
|
7
9
|
* - Windows → PowerShell balloon tip via System.Windows.Forms.NotifyIcon
|
|
8
10
|
*
|
|
@@ -10,10 +12,6 @@
|
|
|
10
12
|
* testable; `osNotify` wraps it with a fire-and-forget spawn that never throws.
|
|
11
13
|
*/
|
|
12
14
|
import { spawnSync } from "node:child_process";
|
|
13
|
-
/** AppleScript string literal escaping: backslash and double-quote only. */
|
|
14
|
-
function escAppleScript(s) {
|
|
15
|
-
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
16
|
-
}
|
|
17
15
|
/** PowerShell single-quoted string literal escaping: double the single quote. */
|
|
18
16
|
function escPowerShellSingle(s) {
|
|
19
17
|
return s.replace(/'/g, "''");
|
|
@@ -23,10 +21,6 @@ function escPowerShellSingle(s) {
|
|
|
23
21
|
* `platform` matches `process.platform` values (`darwin` / `linux` / `win32`).
|
|
24
22
|
*/
|
|
25
23
|
export function buildOsNotifyCommand(platform, title, body) {
|
|
26
|
-
if (platform === "darwin") {
|
|
27
|
-
const script = `display notification "${escAppleScript(body)}" with title "${escAppleScript(title)}"`;
|
|
28
|
-
return { program: "osascript", args: ["-e", script] };
|
|
29
|
-
}
|
|
30
24
|
if (platform === "linux") {
|
|
31
25
|
// notify-send treats the first arg as summary, the second as body.
|
|
32
26
|
return { program: "notify-send", args: [title, body] };
|
package/dist/providers.js
CHANGED
|
@@ -139,18 +139,30 @@ export function provider(id) {
|
|
|
139
139
|
export function allProviders() {
|
|
140
140
|
return PROVIDER_IDS.map((id) => PROVIDERS[id]);
|
|
141
141
|
}
|
|
142
|
-
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Where an installer may drop a binary off the minimal PATH a GUI app (and the
|
|
144
|
+
* service it starts) runs with on macOS — `/usr/bin:/bin:/usr/sbin:/sbin`.
|
|
145
|
+
* ~/.local/bin (e.g. agy via `curl … | bash`) first, then Homebrew on Apple
|
|
146
|
+
* silicon and Intel. The first existing path, or null.
|
|
147
|
+
*/
|
|
148
|
+
export function offPathBinary(binary, home = os.homedir(), exists = fs.existsSync, platform = process.platform) {
|
|
149
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
150
|
+
const dirs = [p.join(home, ".local", "bin")];
|
|
151
|
+
if (platform !== "win32")
|
|
152
|
+
dirs.push("/opt/homebrew/bin", "/usr/local/bin");
|
|
153
|
+
for (const dir of dirs) {
|
|
154
|
+
const file = p.join(dir, binary);
|
|
155
|
+
if (exists(file))
|
|
156
|
+
return file;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
147
159
|
}
|
|
148
160
|
/**
|
|
149
161
|
* Resolve a CLI binary to an invocable command: a user-linked path when set and
|
|
150
162
|
* present (the CLI is not on PATH but the user pointed us at it), otherwise its
|
|
151
|
-
* bare name when it is on PATH, otherwise its
|
|
152
|
-
*
|
|
153
|
-
*
|
|
163
|
+
* bare name when it is on PATH, otherwise its path in one of the
|
|
164
|
+
* {@link offPathBinary} dirs, otherwise the bare name so the caller surfaces a
|
|
165
|
+
* clean "not found" error.
|
|
154
166
|
*/
|
|
155
167
|
export function resolveBinary(binary, linked) {
|
|
156
168
|
if (linked && fs.existsSync(linked))
|
|
@@ -158,14 +170,13 @@ export function resolveBinary(binary, linked) {
|
|
|
158
170
|
const probe = spawnSync(binary, ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
159
171
|
if (probe.error?.code !== "ENOENT")
|
|
160
172
|
return binary;
|
|
161
|
-
|
|
162
|
-
return fs.existsSync(local) ? local : binary;
|
|
173
|
+
return offPathBinary(binary) ?? binary;
|
|
163
174
|
}
|
|
164
175
|
/**
|
|
165
176
|
* Best-effort: is this provider's CLI binary usable? True when a user-linked
|
|
166
177
|
* path is set and present, else when `<binary> --version` resolves on PATH
|
|
167
178
|
* (only ENOENT counts as not-installed — a non-zero exit or a timeout still
|
|
168
|
-
* means the binary exists), else when it lives in
|
|
179
|
+
* means the binary exists), else when it lives in an {@link offPathBinary} dir. Used to gate
|
|
169
180
|
* enabling a provider the user hasn't installed (or hasn't linked).
|
|
170
181
|
*/
|
|
171
182
|
export function isProviderInstalled(id, linked) {
|
|
@@ -175,5 +186,5 @@ export function isProviderInstalled(id, linked) {
|
|
|
175
186
|
const probe = spawnSync(bin, ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
176
187
|
if (probe.error?.code !== "ENOENT")
|
|
177
188
|
return true;
|
|
178
|
-
return
|
|
189
|
+
return offPathBinary(bin) !== null;
|
|
179
190
|
}
|
package/dist/telemetry.js
CHANGED
|
@@ -43,6 +43,10 @@ export const SUPPORTED_CLAUDE = ["2.1"];
|
|
|
43
43
|
* tokens, never guess a percentage). User overrides via state.json merge on
|
|
44
44
|
* top. Codex needs no table — its rollout carries model_context_window. */
|
|
45
45
|
export const CONTEXT_WINDOWS = {
|
|
46
|
+
"claude-fable-5-1": 1_000_000,
|
|
47
|
+
"claude-opus-5-5": 1_000_000,
|
|
48
|
+
"claude-sonnet-5-5": 1_000_000,
|
|
49
|
+
"claude-haiku-5-5": 200_000,
|
|
46
50
|
"claude-fable-5": 1_000_000,
|
|
47
51
|
"claude-opus-4-8": 1_000_000,
|
|
48
52
|
"claude-opus-4-7": 1_000_000,
|
package/dist/token-freshen.js
CHANGED
|
@@ -96,14 +96,24 @@ function recordAttemptFile(configDir, at) {
|
|
|
96
96
|
/* best-effort: a missing stamp only costs an extra attempt */
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The PATH `claude doctor` runs with: the resolved binary's dir and this node's
|
|
101
|
+
* dir ahead of the inherited PATH. An npm-installed `claude` is a
|
|
102
|
+
* `#!/usr/bin/env node` script, and the service's minimal PATH has no node.
|
|
103
|
+
*/
|
|
104
|
+
export function doctorPath(bin, execPath, inherited) {
|
|
105
|
+
const dirs = [path.isAbsolute(bin) ? path.dirname(bin) : null, path.dirname(execPath), ...(inherited ?? "").split(path.delimiter)];
|
|
106
|
+
return [...new Set(dirs.filter((d) => !!d))].join(path.delimiter);
|
|
107
|
+
}
|
|
99
108
|
/**
|
|
100
109
|
* `claude doctor` against one profile's config dir. stdout/stderr are dropped —
|
|
101
110
|
* this is a side-effect call, not a readout. Any completed run returns true;
|
|
102
111
|
* whether the token actually got refreshed is decided by re-reading the store.
|
|
103
112
|
*/
|
|
104
113
|
function runClaudeDoctor(configDir, linkedBinary) {
|
|
105
|
-
const
|
|
106
|
-
|
|
114
|
+
const bin = resolveBinary("claude", linkedBinary);
|
|
115
|
+
const res = spawnSync(bin, ["doctor"], {
|
|
116
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, PATH: doctorPath(bin, process.execPath, process.env.PATH) },
|
|
107
117
|
// Claude Code reads settings from the CWD; run it somewhere neutral so a
|
|
108
118
|
// project's config can never influence a background health check.
|
|
109
119
|
cwd: os.tmpdir(),
|
|
@@ -147,8 +157,9 @@ export function freshenToken(configDir, deps = defaultFreshenDeps()) {
|
|
|
147
157
|
* no readable credential at all); a freshen that could not heal the login just
|
|
148
158
|
* means the caller's read fails as it did before.
|
|
149
159
|
*/
|
|
150
|
-
export function freshAccessToken(configDir, deps = defaultFreshenDeps()) {
|
|
151
|
-
freshenToken(configDir, deps);
|
|
160
|
+
export function freshAccessToken(configDir, deps = defaultFreshenDeps(), onOutcome) {
|
|
161
|
+
const outcome = freshenToken(configDir, deps);
|
|
162
|
+
onOutcome?.(outcome);
|
|
152
163
|
const creds = deps.readCredential(configDir);
|
|
153
164
|
return creds ? accessTokenOf(creds) : null;
|
|
154
165
|
}
|
|
@@ -157,9 +168,9 @@ export function freshAccessToken(configDir, deps = defaultFreshenDeps()) {
|
|
|
157
168
|
* install (seeded by `import`) is never refreshed: whichever side refreshes
|
|
158
169
|
* first invalidates the other, so its stored token is used only while valid.
|
|
159
170
|
*/
|
|
160
|
-
export function usageToken(configDir, deps, sharesDefaultLogin) {
|
|
171
|
+
export function usageToken(configDir, deps, sharesDefaultLogin, onOutcome) {
|
|
161
172
|
if (!sharesDefaultLogin)
|
|
162
|
-
return freshAccessToken(configDir, deps);
|
|
173
|
+
return freshAccessToken(configDir, deps, onOutcome);
|
|
163
174
|
const creds = deps.readCredential(configDir);
|
|
164
175
|
return creds && !needsFreshen(creds, deps.now(), deps.bufferMs) ? accessTokenOf(creds) : null;
|
|
165
176
|
}
|
package/package.json
CHANGED