@event4u/agent-switch 1.0.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.
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/dist/agy-keychain.js +70 -0
- package/dist/api.js +154 -0
- package/dist/apps.js +112 -0
- package/dist/args.js +85 -0
- package/dist/codex-reset.js +93 -0
- package/dist/codex-usage.js +105 -0
- package/dist/credentials.js +66 -0
- package/dist/daemon.js +393 -0
- package/dist/doctor.js +119 -0
- package/dist/gui-launch.js +91 -0
- package/dist/handoff.js +167 -0
- package/dist/history.js +35 -0
- package/dist/hooks.js +142 -0
- package/dist/index.js +1693 -0
- package/dist/keychain.js +54 -0
- package/dist/locks.js +89 -0
- package/dist/mappings.js +132 -0
- package/dist/notifications.js +70 -0
- package/dist/notify.js +84 -0
- package/dist/os-notify.js +66 -0
- package/dist/profiles.js +376 -0
- package/dist/providers.js +170 -0
- package/dist/service.js +223 -0
- package/dist/session-preview.js +163 -0
- package/dist/sessions.js +543 -0
- package/dist/settings-inherit.js +81 -0
- package/dist/share.js +257 -0
- package/dist/shellenv.js +116 -0
- package/dist/telemetry.js +269 -0
- package/dist/tmux.js +98 -0
- package/dist/tokens.js +88 -0
- package/dist/usage.js +208 -0
- package/package.json +39 -0
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background usage daemon — poller, cache, and (opt-in) auto-switcher.
|
|
3
|
+
*
|
|
4
|
+
* It polls Claude profiles for their OWN usage, appends history, detects
|
|
5
|
+
* active-profile threshold crossings, and writes `daemon-state.json` so
|
|
6
|
+
* `status`/the GUI can read a fresh snapshot without hitting the API. When
|
|
7
|
+
* `autoSwitch.enabled` (default OFF) it also watches every profile and moves the
|
|
8
|
+
* active pointer to the account with the most headroom once the active one hits
|
|
9
|
+
* the configured threshold — pooling accounts to route around limits, which the
|
|
10
|
+
* operator opts into deliberately (see `AutoSwitchConfig`). With auto-switch off
|
|
11
|
+
* it stays a pure poller/cache that never mutates the active profile.
|
|
12
|
+
*
|
|
13
|
+
* The pure helpers (single-instance, poll-target selection, backoff, state
|
|
14
|
+
* freshness, switch decision in `usage.ts`) are exported for unit testing;
|
|
15
|
+
* `runDaemon` is the loop.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { ROOT, activeFor, configDir, labelFor, listProfiles, readAutoSwitch, setActive, readSwitchStrategy } from "./profiles.js";
|
|
20
|
+
import { profileDir } from "./profiles.js";
|
|
21
|
+
import { accessTokenOf, fetchUsage, liveSessionPids, readProfileCredential } from "./api.js";
|
|
22
|
+
import { parseUsage, detectCrossings, pickSwitchTarget, maxUtilization } from "./usage.js";
|
|
23
|
+
import { readCodexUsage } from "./codex-usage.js";
|
|
24
|
+
import { redeemResetCredit } from "./codex-reset.js";
|
|
25
|
+
import { appendSample } from "./history.js";
|
|
26
|
+
import { appendNotification, markOsNotified } from "./notifications.js";
|
|
27
|
+
import { readOsNotifications } from "./profiles.js";
|
|
28
|
+
import { osNotify } from "./os-notify.js";
|
|
29
|
+
import { listSessions, listCodexSessions, markLive } from "./sessions.js";
|
|
30
|
+
import { readContext } from "./telemetry.js";
|
|
31
|
+
import { readTelemetryConfig, detectContextCrossings, coalesce, } from "./notify.js";
|
|
32
|
+
import { readEvents, eventFile } from "./hooks.js";
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
/** Append an event to the shared log and, when the operator opted into
|
|
35
|
+
* daemon-side OS notifications, fire a desktop notification too — marking the
|
|
36
|
+
* record so the GUI does not show it a second time. Deduped events (append
|
|
37
|
+
* returns null) never OS-notify. */
|
|
38
|
+
function notify(event) {
|
|
39
|
+
const created = appendNotification(event);
|
|
40
|
+
if (created && readOsNotifications() && osNotify(event.title, event.message)) {
|
|
41
|
+
markOsNotified(created.id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Providers whose active profile the daemon may auto-switch (they expose a
|
|
45
|
+
// usage readout to base a headroom decision on). Antigravity has none.
|
|
46
|
+
const AUTO_PROVIDERS = ["claude", "codex"];
|
|
47
|
+
export const PIDFILE = path.join(ROOT, "daemon.pid");
|
|
48
|
+
export const STATE_FILE = path.join(ROOT, "daemon-state.json");
|
|
49
|
+
export const MIN_INTERVAL_MS = 60_000; // never poll faster than once a minute
|
|
50
|
+
export const MAX_BACKOFF_MS = 30 * 60_000; // cap backoff at 30 min
|
|
51
|
+
// ---------- state file (the cache) ----------
|
|
52
|
+
export function readDaemonState(file = STATE_FILE) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function writeDaemonState(state, file = STATE_FILE) {
|
|
61
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
62
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
/** True when the state's lastPoll is within maxAgeMs of `now` — the CLI/GUI use
|
|
65
|
+
* this to decide whether to trust the cache instead of hitting the API. */
|
|
66
|
+
export function isFresh(state, maxAgeMs, now = Date.now()) {
|
|
67
|
+
if (!state?.lastPoll)
|
|
68
|
+
return false;
|
|
69
|
+
const t = Date.parse(state.lastPoll);
|
|
70
|
+
return !isNaN(t) && now - t <= maxAgeMs;
|
|
71
|
+
}
|
|
72
|
+
// ---------- single instance (pidfile) ----------
|
|
73
|
+
export function processAlive(pid) {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, 0);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return err?.code === "EPERM";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function readPid(file = PIDFILE) {
|
|
83
|
+
try {
|
|
84
|
+
const n = Number(fs.readFileSync(file, "utf8").trim());
|
|
85
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Acquire the single-instance lock. Returns true and writes the pidfile when no
|
|
93
|
+
* live daemon holds it (taking over a stale pidfile); false when another live
|
|
94
|
+
* daemon is already running.
|
|
95
|
+
*/
|
|
96
|
+
export function acquireSingleInstance(myPid, file = PIDFILE) {
|
|
97
|
+
const existing = readPid(file);
|
|
98
|
+
if (existing && existing !== myPid && processAlive(existing))
|
|
99
|
+
return false;
|
|
100
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
101
|
+
fs.writeFileSync(file, String(myPid) + "\n", { mode: 0o600 });
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
export function releaseSingleInstance(myPid, file = PIDFILE) {
|
|
105
|
+
if (readPid(file) === myPid) {
|
|
106
|
+
try {
|
|
107
|
+
fs.rmSync(file, { force: true });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* best-effort */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ---------- poll discipline ----------
|
|
115
|
+
/**
|
|
116
|
+
* Which Claude profiles to poll this cycle. Normally the active one plus any
|
|
117
|
+
* with a live session — never a busy-poll of idle accounts. With `watchAll`
|
|
118
|
+
* (auto-switch enabled) every profile is polled, since the switch decision
|
|
119
|
+
* needs each candidate's headroom. `liveCheck` is injected for testability.
|
|
120
|
+
*/
|
|
121
|
+
export function selectPollTargets(claudeNames, active, liveCheck, watchAll = false) {
|
|
122
|
+
if (watchAll)
|
|
123
|
+
return [...claudeNames];
|
|
124
|
+
const targets = new Set();
|
|
125
|
+
if (active && claudeNames.includes(active))
|
|
126
|
+
targets.add(active);
|
|
127
|
+
for (const n of claudeNames)
|
|
128
|
+
if (liveCheck(n))
|
|
129
|
+
targets.add(n);
|
|
130
|
+
return [...targets];
|
|
131
|
+
}
|
|
132
|
+
/** Exponential backoff on consecutive failures, floored at the base interval
|
|
133
|
+
* and capped at MAX_BACKOFF_MS. */
|
|
134
|
+
export function nextIntervalMs(baseMs, consecutiveFailures, capMs = MAX_BACKOFF_MS) {
|
|
135
|
+
const base = Math.max(baseMs, MIN_INTERVAL_MS);
|
|
136
|
+
if (consecutiveFailures <= 0)
|
|
137
|
+
return base;
|
|
138
|
+
return Math.min(base * 2 ** consecutiveFailures, capMs);
|
|
139
|
+
}
|
|
140
|
+
// ---------- context monitoring (own-session; Phase 3) ----------
|
|
141
|
+
let _claudeVer;
|
|
142
|
+
function claudeVer() {
|
|
143
|
+
if (_claudeVer !== undefined)
|
|
144
|
+
return _claudeVer;
|
|
145
|
+
try {
|
|
146
|
+
_claudeVer = execFileSync("claude", ["--version"], { encoding: "utf8" }).trim() || null;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
_claudeVer = null;
|
|
150
|
+
}
|
|
151
|
+
return _claudeVer;
|
|
152
|
+
}
|
|
153
|
+
/** Session ids for `provider/name` that saw a real compaction (or a fresh
|
|
154
|
+
* clear) since `sinceISO`, from the hook event ring — the ground-truth re-arm
|
|
155
|
+
* signal (Phase 2.5). Empty when hooks are not installed. */
|
|
156
|
+
function compactedSince(provider, name, sinceISO) {
|
|
157
|
+
const out = new Set();
|
|
158
|
+
const since = sinceISO ? Date.parse(sinceISO) : 0;
|
|
159
|
+
for (const e of readEvents(eventFile(ROOT, provider, name))) {
|
|
160
|
+
if (!e.sessionId)
|
|
161
|
+
continue;
|
|
162
|
+
const isCompact = e.event === "PreCompact" || e.event === "PostCompact" || (e.event === "SessionStart" && (e.source === "compact" || e.source === "clear"));
|
|
163
|
+
if (isCompact && (!since || Date.parse(e.at) >= since))
|
|
164
|
+
out.add(e.sessionId);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Tail the active profile's LIVE sessions, snapshot their own context into the
|
|
170
|
+
* daemon state, detect per-session threshold crossings (edge-triggered, re-armed
|
|
171
|
+
* on real compaction events), and record ONE coalesced event in the shared
|
|
172
|
+
* notification log per cycle when alerts are enabled. Local file reads only —
|
|
173
|
+
* no API calls. Own-session only: nothing here compares profiles. Best-effort;
|
|
174
|
+
* never throws.
|
|
175
|
+
*/
|
|
176
|
+
export function monitorContext(provider, name, state, log) {
|
|
177
|
+
try {
|
|
178
|
+
if (provider !== "claude" && provider !== "codex")
|
|
179
|
+
return;
|
|
180
|
+
const cfg = configDir(provider, name);
|
|
181
|
+
const rows = provider === "codex" ? listCodexSessions(cfg, 30) : listSessions(cfg, 30);
|
|
182
|
+
if (provider === "claude")
|
|
183
|
+
markLive(cfg, rows);
|
|
184
|
+
const live = rows.filter((r) => r.live && r.file);
|
|
185
|
+
const key = `${provider}/${name}`;
|
|
186
|
+
const now = new Date().toISOString();
|
|
187
|
+
const samples = [];
|
|
188
|
+
const snapshots = [];
|
|
189
|
+
for (const r of live) {
|
|
190
|
+
const c = readContext(provider, r.file, { claudeVersion: claudeVer() });
|
|
191
|
+
if (!c)
|
|
192
|
+
continue;
|
|
193
|
+
const where = (r.cwd ?? r.projectDir).split("/").filter(Boolean).slice(-1)[0] || r.projectDir;
|
|
194
|
+
samples.push({ sessionId: r.sessionId, pct: c.pct, where });
|
|
195
|
+
snapshots.push({ sessionId: r.sessionId, pct: c.pct, contextTokens: c.contextTokens, windowTokens: c.windowTokens, where, confidence: c.confidence, at: now });
|
|
196
|
+
}
|
|
197
|
+
state.sessionContext = state.sessionContext ?? {};
|
|
198
|
+
state.sessionContext[key] = snapshots;
|
|
199
|
+
const cfgT = readTelemetryConfig(ROOT);
|
|
200
|
+
const prev = (state.contextThresholds ?? {})[key] ?? {};
|
|
201
|
+
const compacted = compactedSince(provider, name, state.lastPoll);
|
|
202
|
+
const { crossings, state: next } = detectContextCrossings(samples, prev, cfgT.contextThresholds, compacted);
|
|
203
|
+
state.contextThresholds = state.contextThresholds ?? {};
|
|
204
|
+
state.contextThresholds[key] = next;
|
|
205
|
+
for (const c of crossings)
|
|
206
|
+
log(`context: ${provider}/${name} ${c.where} crossed ${c.threshold}% (now ${c.pct}%)`);
|
|
207
|
+
// Feed the ONE coalesced crossing into the shared notification log (the app's
|
|
208
|
+
// single notifier: the GUI bell/flyout reads it and fires a best-effort
|
|
209
|
+
// desktop notification). Off by default via the alerts config.
|
|
210
|
+
const note = coalesce(crossings);
|
|
211
|
+
if (note && cfgT.notify) {
|
|
212
|
+
appendNotification({ kind: "warning", title: note.title, message: note.body });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
log(`context monitor error: ${String(err?.message ?? err)}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** One poll cycle: refresh the state cache for the selected profiles. Returns
|
|
220
|
+
* the number of failures this cycle (for backoff). */
|
|
221
|
+
/** One profile's own usage snapshot: Claude via its OAuth endpoint, Codex via
|
|
222
|
+
* the live `wham/usage` read. null when unreadable (counts as a poll failure). */
|
|
223
|
+
async function snapshotFor(provider, name) {
|
|
224
|
+
if (provider === "codex")
|
|
225
|
+
return readCodexUsage(configDir("codex", name));
|
|
226
|
+
const creds = readProfileCredential(configDir("claude", name));
|
|
227
|
+
const token = creds ? accessTokenOf(creds) : null;
|
|
228
|
+
if (!token)
|
|
229
|
+
return null;
|
|
230
|
+
const raw = await fetchUsage(token);
|
|
231
|
+
return raw ? parseUsage(raw) : null;
|
|
232
|
+
}
|
|
233
|
+
async function pollProvider(provider, strategy, state, thresholds, redeemed, log) {
|
|
234
|
+
const names = listProfiles(provider);
|
|
235
|
+
const active = activeFor(provider);
|
|
236
|
+
const autoSwitch = readAutoSwitch(provider);
|
|
237
|
+
const isLive = (n) => (provider === "claude" ? liveSessionPids(configDir("claude", n)).length > 0 : false);
|
|
238
|
+
const targets = selectPollTargets(names, active, isLive, autoSwitch.enabled);
|
|
239
|
+
let failures = 0;
|
|
240
|
+
const polled = [];
|
|
241
|
+
state.notifiedFetchFail = state.notifiedFetchFail ?? {};
|
|
242
|
+
for (const name of targets) {
|
|
243
|
+
const key = `${provider}/${name}`;
|
|
244
|
+
const snapshot = await snapshotFor(provider, name);
|
|
245
|
+
if (!snapshot) {
|
|
246
|
+
failures++;
|
|
247
|
+
// Fail-safe: the last-known snapshot in state.profiles[key] is left intact
|
|
248
|
+
// (never overwritten with null), and we warn ONCE per outage rather than
|
|
249
|
+
// every cycle — the loop keeps serving the last good numbers meanwhile.
|
|
250
|
+
if (!state.notifiedFetchFail[key]) {
|
|
251
|
+
state.notifiedFetchFail[key] = true;
|
|
252
|
+
notify({
|
|
253
|
+
kind: "warning",
|
|
254
|
+
title: "Usage fetch failed",
|
|
255
|
+
message: `Could not fetch usage limits for ${provider}/${name}. Keeping the last known values.`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (state.notifiedFetchFail[key]) {
|
|
261
|
+
delete state.notifiedFetchFail[key];
|
|
262
|
+
log(`recovered: ${provider}/${name} usage fetch succeeded again`);
|
|
263
|
+
}
|
|
264
|
+
state.profiles[key] = snapshot;
|
|
265
|
+
polled.push({ name, snapshot });
|
|
266
|
+
appendSample(path.join(profileDir(provider, name), "usage-history.json"), snapshot);
|
|
267
|
+
if (name === active) {
|
|
268
|
+
const prev = thresholds.get(`${provider}/${name}`) ?? {};
|
|
269
|
+
const { crossings, state: next } = detectCrossings(snapshot, prev);
|
|
270
|
+
thresholds.set(`${provider}/${name}`, next);
|
|
271
|
+
for (const c of crossings) {
|
|
272
|
+
log(`threshold: ${provider}/${name} ${c.window} crossed ${c.threshold}% (now ${c.utilization}%)`);
|
|
273
|
+
notify({
|
|
274
|
+
kind: "info",
|
|
275
|
+
title: "Usage threshold crossed",
|
|
276
|
+
message: `${provider}/${name} ${c.window} passed ${c.threshold}% (now ${c.utilization}%).`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Context monitoring is local file I/O only — it must run for the active
|
|
282
|
+
// profile even when the usage API poll failed (expired token, offline).
|
|
283
|
+
if (active)
|
|
284
|
+
monitorContext(provider, active, state, log);
|
|
285
|
+
if (!autoSwitch.enabled || !active)
|
|
286
|
+
return failures;
|
|
287
|
+
const activeSnap = polled.find((p) => p.name === active)?.snapshot;
|
|
288
|
+
const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
|
|
289
|
+
if (activeMax === null || activeMax < autoSwitch.threshold)
|
|
290
|
+
return failures; // still has headroom
|
|
291
|
+
// reset-first (Codex only — banked resets are a Codex feature): redeem a reset
|
|
292
|
+
// instead of switching, but AT MOST ONCE per reset cycle. We record the
|
|
293
|
+
// window's resets_at BEFORE calling, so a buggy reset that doesn't actually
|
|
294
|
+
// clear usage can never loop and burn the whole balance — it falls through to
|
|
295
|
+
// a profile switch on the same poll's next-cycle.
|
|
296
|
+
if (strategy === "reset-first" && provider === "codex" && activeSnap) {
|
|
297
|
+
const stuck = activeSnap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= autoSwitch.threshold);
|
|
298
|
+
const cycle = stuck?.resetsAt ?? "";
|
|
299
|
+
const guardKey = `codex/${active}`;
|
|
300
|
+
if ((activeSnap.resetCredits ?? 0) > 0 && redeemed.get(guardKey) !== cycle) {
|
|
301
|
+
redeemed.set(guardKey, cycle);
|
|
302
|
+
const r = await redeemResetCredit(configDir("codex", active));
|
|
303
|
+
if (r.ok) {
|
|
304
|
+
log(`auto-switch: codex/${active} over ≥${autoSwitch.threshold}% → redeemed a reset (windows_reset=${r.windowsReset ?? "?"}); staying on ${active}`);
|
|
305
|
+
notify({
|
|
306
|
+
kind: "success",
|
|
307
|
+
title: "Codex reset redeemed",
|
|
308
|
+
message: `codex/${active} hit ≥${autoSwitch.threshold}% — redeemed a banked reset and stayed on ${active}.`,
|
|
309
|
+
});
|
|
310
|
+
return failures;
|
|
311
|
+
}
|
|
312
|
+
log(`auto-switch: codex/${active} reset redeem failed (${r.reason ?? "?"}) → falling back to a profile switch`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Tag filter: only accounts carrying the configured label are eligible switch
|
|
316
|
+
// targets ("all" = no filter). Lets the operator pool e.g. only Work accounts.
|
|
317
|
+
const eligible = (name) => autoSwitch.tag === "all" || labelFor(provider, name) === autoSwitch.tag;
|
|
318
|
+
const target = pickSwitchTarget(active, polled, autoSwitch.threshold, eligible);
|
|
319
|
+
if (target && target !== active) {
|
|
320
|
+
setActive(provider, target);
|
|
321
|
+
log(`auto-switch: ${provider}/${active} out of headroom (≥${autoSwitch.threshold}%) → switched active to ${provider}/${target}`);
|
|
322
|
+
notify({
|
|
323
|
+
kind: "success",
|
|
324
|
+
title: "Auto-switched account",
|
|
325
|
+
message: `${provider}/${active} hit ≥${autoSwitch.threshold}% — switched active to ${provider}/${target}.`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return failures;
|
|
329
|
+
}
|
|
330
|
+
async function pollOnce(state, thresholds, redeemed, log) {
|
|
331
|
+
const strategy = readSwitchStrategy();
|
|
332
|
+
let failures = 0;
|
|
333
|
+
for (const provider of AUTO_PROVIDERS) {
|
|
334
|
+
failures += await pollProvider(provider, strategy, state, thresholds, redeemed, log);
|
|
335
|
+
}
|
|
336
|
+
state.lastPoll = new Date().toISOString();
|
|
337
|
+
state.lastError = failures > 0 ? `${failures} profile(s) failed to poll` : null;
|
|
338
|
+
return failures;
|
|
339
|
+
}
|
|
340
|
+
/** Run the daemon loop. Single-instance; SIGTERM-clean. */
|
|
341
|
+
export async function runDaemon(opts = {}) {
|
|
342
|
+
const log = opts.log ?? ((l) => console.log(`[daemon] ${new Date().toISOString()} ${l}`));
|
|
343
|
+
const baseInterval = Math.max(opts.intervalMs ?? MIN_INTERVAL_MS, MIN_INTERVAL_MS);
|
|
344
|
+
if (!acquireSingleInstance(process.pid)) {
|
|
345
|
+
log("another daemon is already running — exiting.");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let running = true;
|
|
349
|
+
const stop = () => {
|
|
350
|
+
running = false;
|
|
351
|
+
releaseSingleInstance(process.pid);
|
|
352
|
+
log("stopped.");
|
|
353
|
+
process.exit(0);
|
|
354
|
+
};
|
|
355
|
+
process.on("SIGTERM", stop);
|
|
356
|
+
process.on("SIGINT", stop);
|
|
357
|
+
const state = readDaemonState() ?? {
|
|
358
|
+
lastPoll: null,
|
|
359
|
+
pollIntervalMs: baseInterval,
|
|
360
|
+
profiles: {},
|
|
361
|
+
lastError: null,
|
|
362
|
+
};
|
|
363
|
+
// Load persisted usage-window fired state so crossings do NOT re-fire after a
|
|
364
|
+
// daemon restart within the same window cycle (the pre-existing in-memory gap).
|
|
365
|
+
const thresholds = new Map(Object.entries(state.usageThresholds ?? {}));
|
|
366
|
+
// Anti-loop guard for reset-first: `<provider>/<name>` → the window resets_at
|
|
367
|
+
// we last redeemed for, so we never redeem the same stuck cycle twice.
|
|
368
|
+
const redeemed = new Map();
|
|
369
|
+
let consecutiveFailures = 0;
|
|
370
|
+
let cycles = 0;
|
|
371
|
+
log(`started (pid ${process.pid}, base interval ${baseInterval}ms).`);
|
|
372
|
+
while (running) {
|
|
373
|
+
try {
|
|
374
|
+
const failures = await pollOnce(state, thresholds, redeemed, log);
|
|
375
|
+
consecutiveFailures = failures > 0 ? consecutiveFailures + 1 : 0;
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
consecutiveFailures++;
|
|
379
|
+
state.lastError = String(err?.message ?? err);
|
|
380
|
+
log(`poll error: ${state.lastError}`);
|
|
381
|
+
}
|
|
382
|
+
const wait = nextIntervalMs(baseInterval, consecutiveFailures);
|
|
383
|
+
state.pollIntervalMs = wait;
|
|
384
|
+
state.usageThresholds = Object.fromEntries(thresholds); // persist for restart
|
|
385
|
+
writeDaemonState(state);
|
|
386
|
+
if (opts.maxCycles !== undefined && ++cycles >= opts.maxCycles)
|
|
387
|
+
break;
|
|
388
|
+
// Jitter ±10% so many machines don't poll in lockstep.
|
|
389
|
+
const jitter = wait * 0.1 * (Math.random() * 2 - 1);
|
|
390
|
+
await new Promise((r) => setTimeout(r, wait + jitter));
|
|
391
|
+
}
|
|
392
|
+
releaseSingleInstance(process.pid);
|
|
393
|
+
}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-switch doctor` — a per-OS, per-provider self-check.
|
|
3
|
+
*
|
|
4
|
+
* Reports health with actionable fixes and returns an exit code: 0 when there
|
|
5
|
+
* is no hard error, 1 when a provider that has profiles is missing its binary.
|
|
6
|
+
* Read-only — it never mutates a profile.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { ROOT, activeFor, configDir, listProfiles, profileExists } from "./profiles.js";
|
|
11
|
+
import { allProviders } from "./providers.js";
|
|
12
|
+
import { credentialStore } from "./credentials.js";
|
|
13
|
+
import { sharedLinkHealth } from "./share.js";
|
|
14
|
+
import { checkAuth } from "./api.js";
|
|
15
|
+
const OK = "✅";
|
|
16
|
+
const WARN = "⚠️";
|
|
17
|
+
const ERR = "❌";
|
|
18
|
+
function binaryOnPath(binary) {
|
|
19
|
+
// Timeout so a hung binary can't hang the doctor.
|
|
20
|
+
const probe = spawnSync(binary, ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
21
|
+
if (probe.error?.code === "ENOENT")
|
|
22
|
+
return "no";
|
|
23
|
+
if (probe.error)
|
|
24
|
+
return "error";
|
|
25
|
+
return "yes";
|
|
26
|
+
}
|
|
27
|
+
function credentialReadable(p, name) {
|
|
28
|
+
const cfg = p.configDirFor(configDir(p.id, name));
|
|
29
|
+
if (p.id === "claude")
|
|
30
|
+
return credentialStore().read(cfg) !== null;
|
|
31
|
+
try {
|
|
32
|
+
fs.accessSync(p.credentialPath(cfg));
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function runDoctor() {
|
|
40
|
+
let hardError = false;
|
|
41
|
+
const line = (mark, msg) => console.log(`${mark} ${msg}`);
|
|
42
|
+
console.log(`agent-switch doctor — platform ${process.platform}, node ${process.version}`);
|
|
43
|
+
console.log(process.platform === "darwin"
|
|
44
|
+
? " claude credential store: macOS Keychain (hashed per config dir), file fallback"
|
|
45
|
+
: " claude credential store: plaintext .credentials.json per profile");
|
|
46
|
+
console.log("");
|
|
47
|
+
for (const p of allProviders()) {
|
|
48
|
+
const names = listProfiles(p.id);
|
|
49
|
+
const onPath = binaryOnPath(p.binary);
|
|
50
|
+
if (onPath === "no") {
|
|
51
|
+
if (names.length > 0) {
|
|
52
|
+
line(ERR, `\`${p.binary}\` not on PATH but ${names.length} ${p.id} profile(s) exist — install ${p.id}.`);
|
|
53
|
+
hardError = true;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
line(WARN, `\`${p.binary}\` not on PATH (no ${p.id} profiles yet — fine unless you use ${p.id}).`);
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (onPath === "error")
|
|
61
|
+
line(WARN, `could not probe \`${p.binary} --version\`.`);
|
|
62
|
+
else
|
|
63
|
+
line(OK, `\`${p.binary}\` is on PATH.`);
|
|
64
|
+
if (names.length === 0) {
|
|
65
|
+
line(WARN, ` no ${p.id} profiles yet — \`agent-switch add <name>${p.id === "claude" ? "" : ` --provider ${p.id}`}\`.`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
line(OK, ` ${names.length} ${p.id} profile(s): ${names.join(", ")}.`);
|
|
69
|
+
const active = activeFor(p.id);
|
|
70
|
+
if (active && !profileExists(p.id, active)) {
|
|
71
|
+
line(WARN, ` active ${p.id} profile "${active}" no longer exists.`);
|
|
72
|
+
}
|
|
73
|
+
// Login validity (Claude only): probe readable credentials in parallel, so
|
|
74
|
+
// an expired login is caught here instead of failing silently at next use.
|
|
75
|
+
// Read-only; offline / transient degrades to a neutral note, never a WARN.
|
|
76
|
+
const authByName = new Map();
|
|
77
|
+
if (p.id === "claude") {
|
|
78
|
+
const readable = names.filter((n) => credentialReadable(p, n));
|
|
79
|
+
const states = await Promise.all(readable.map((n) => checkAuth(p.configDirFor(configDir(p.id, n)))));
|
|
80
|
+
readable.forEach((n, i) => authByName.set(n, states[i]));
|
|
81
|
+
}
|
|
82
|
+
for (const n of names) {
|
|
83
|
+
if (!credentialReadable(p, n)) {
|
|
84
|
+
line(WARN, ` ${p.id}/${n}: credential not readable — \`agent-switch run ${n}${p.id === "claude" ? "" : ` --provider ${p.id}`}\` and log in.`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const auth = authByName.get(n);
|
|
88
|
+
if (auth === "expired") {
|
|
89
|
+
line(WARN, ` ${p.id}/${n}: login expired — \`agent-switch run ${n}\` and /login again.`);
|
|
90
|
+
}
|
|
91
|
+
else if (auth === "ok") {
|
|
92
|
+
line(OK, ` ${p.id}/${n}: credential readable, login valid.`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// Non-Claude (no probe) or Claude "unknown"/offline — don't over-claim.
|
|
96
|
+
line(OK, ` ${p.id}/${n}: credential readable${p.id === "claude" ? " (login not verified — offline?)" : ""}.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Share-link health (Claude-config sharing).
|
|
101
|
+
for (const n of listProfiles("claude")) {
|
|
102
|
+
const health = sharedLinkHealth(configDir("claude", n));
|
|
103
|
+
if (health.length === 0)
|
|
104
|
+
continue;
|
|
105
|
+
const forked = health.filter((h) => h.state === "forked").map((h) => h.name);
|
|
106
|
+
const missing = health.filter((h) => h.state === "missing").map((h) => h.name);
|
|
107
|
+
if (forked.length === 0 && missing.length === 0) {
|
|
108
|
+
line(OK, `claude/${n}: ${health.length} shared link(s) intact.`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const parts = [forked.length ? `forked: ${forked.join(", ")}` : "", missing.length ? `missing: ${missing.join(", ")}` : ""].filter(Boolean);
|
|
112
|
+
line(WARN, `claude/${n}: ${parts.join("; ")} — run \`agent-switch share sync\`.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
console.log(`\n profile root: ${ROOT}`);
|
|
116
|
+
console.log("");
|
|
117
|
+
line(hardError ? ERR : OK, hardError ? "doctor found a blocking problem (see above)." : "no blocking problems.");
|
|
118
|
+
return hardError ? 1 : 0;
|
|
119
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-switch gui` — launch the desktop tray GUI.
|
|
3
|
+
*
|
|
4
|
+
* The GUI is a native (Tauri) binary, so it can't run "as JS". Instead the
|
|
5
|
+
* prebuilt binary ships as a per-platform optional-dependency npm package
|
|
6
|
+
* (`@event4u/agent-switch-<platform>`, the esbuild pattern), installed
|
|
7
|
+
* automatically alongside the CLI. Because it arrives via npm — not a
|
|
8
|
+
* browser-downloaded DMG/exe — it carries no quarantine flag, so it launches
|
|
9
|
+
* without the Gatekeeper / SmartScreen "unverified developer" block.
|
|
10
|
+
*
|
|
11
|
+
* The platform→package / artifact / launch-argv resolution is pure and tested;
|
|
12
|
+
* only {@link launchGui} touches the filesystem and spawns a process.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
/** The optional-dependency package carrying the GUI binary for a platform+arch,
|
|
20
|
+
* or null when the platform is unsupported. macOS ships a universal binary, so
|
|
21
|
+
* one darwin package serves both arches. Pure. */
|
|
22
|
+
export function guiPackageFor(platform = process.platform, arch = process.arch) {
|
|
23
|
+
if (platform === "darwin")
|
|
24
|
+
return "@event4u/agent-switch-darwin";
|
|
25
|
+
if (platform === "win32" && arch === "x64")
|
|
26
|
+
return "@event4u/agent-switch-win32-x64";
|
|
27
|
+
if (platform === "linux" && arch === "x64")
|
|
28
|
+
return "@event4u/agent-switch-linux-x64";
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/** The artifact file name inside a platform package. macOS ships the `.app`
|
|
32
|
+
* bundle (launched via `open`), Windows the `.exe`, Linux the raw binary. Pure. */
|
|
33
|
+
export function guiArtifactName(platform = process.platform) {
|
|
34
|
+
if (platform === "darwin")
|
|
35
|
+
return "agent-switch.app";
|
|
36
|
+
if (platform === "win32")
|
|
37
|
+
return "agent-switch.exe";
|
|
38
|
+
if (platform === "linux")
|
|
39
|
+
return "agent-switch";
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/** Program + args to launch the artifact at `artifactPath`. macOS uses `open`
|
|
43
|
+
* (the artifact is an .app bundle); elsewhere the binary is executed directly.
|
|
44
|
+
* Pure — the launch convention split out for testing. */
|
|
45
|
+
export function guiLaunchArgv(platform, artifactPath) {
|
|
46
|
+
if (platform === "darwin")
|
|
47
|
+
return { program: "open", args: ["-n", artifactPath] };
|
|
48
|
+
return { program: artifactPath, args: [] };
|
|
49
|
+
}
|
|
50
|
+
/** Absolute path to the GUI artifact for the host, or null when the platform
|
|
51
|
+
* package isn't installed / the artifact is missing. */
|
|
52
|
+
export function resolveGuiArtifact(platform = process.platform, arch = process.arch) {
|
|
53
|
+
const pkg = guiPackageFor(platform, arch);
|
|
54
|
+
const artifact = guiArtifactName(platform);
|
|
55
|
+
if (!pkg || !artifact)
|
|
56
|
+
return null;
|
|
57
|
+
let pkgDir;
|
|
58
|
+
try {
|
|
59
|
+
pkgDir = path.dirname(require.resolve(`${pkg}/package.json`));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null; // optional dependency not installed for this platform
|
|
63
|
+
}
|
|
64
|
+
const p = path.join(pkgDir, "bin", artifact);
|
|
65
|
+
return fs.existsSync(p) ? p : null;
|
|
66
|
+
}
|
|
67
|
+
/** Launch the desktop GUI, detached so the CLI returns immediately. Throws a
|
|
68
|
+
* helpful error when the platform is unsupported or the package is absent. */
|
|
69
|
+
export function launchGui() {
|
|
70
|
+
const pkg = guiPackageFor();
|
|
71
|
+
if (!pkg) {
|
|
72
|
+
throw new Error(`the desktop GUI has no prebuilt binary for ${process.platform}/${process.arch} — build it from source with \`task gui:build\`.`);
|
|
73
|
+
}
|
|
74
|
+
const artifact = resolveGuiArtifact();
|
|
75
|
+
if (!artifact) {
|
|
76
|
+
throw new Error(`GUI binary not found — the platform package "${pkg}" is not installed.\n` +
|
|
77
|
+
`Reinstall the CLI so npm pulls it: \`npm install -g @event4u/agent-switch\`.`);
|
|
78
|
+
}
|
|
79
|
+
// On POSIX the raw binary needs the executable bit (npm can drop it).
|
|
80
|
+
if (process.platform !== "win32" && process.platform !== "darwin") {
|
|
81
|
+
try {
|
|
82
|
+
fs.chmodSync(artifact, 0o755);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* best-effort */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const { program, args } = guiLaunchArgv(process.platform, artifact);
|
|
89
|
+
const child = spawn(program, args, { detached: true, stdio: "ignore" });
|
|
90
|
+
child.unref();
|
|
91
|
+
}
|