@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/tmux.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in tmux integration for in-place session handoff (POSIX only).
|
|
3
|
+
*
|
|
4
|
+
* `run --tmux` wraps a provider session in an agent-switch-MANAGED tmux session
|
|
5
|
+
* (a recorded name). `takeover --in-place` then hands the account over *inside
|
|
6
|
+
* that same pane*: send a clean exit to the running CLI, wait for it to exit,
|
|
7
|
+
* and respawn the pane with the target profile's env — no new terminal.
|
|
8
|
+
*
|
|
9
|
+
* Only agent-switch-managed panes are ever touched (the recorded-name check);
|
|
10
|
+
* a session running in a user's own tmux/terminal is never send-keys'd.
|
|
11
|
+
*
|
|
12
|
+
* The argv builders here are pure (unit-tested); the thin exec + the recorded
|
|
13
|
+
* managed-session file are the only side effects.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { ROOT } from "./profiles.js";
|
|
19
|
+
/** `<ROOT>/tmux-sessions.json`: tmux session name → the profile it manages. */
|
|
20
|
+
export const TMUX_STATE = path.join(ROOT, "tmux-sessions.json");
|
|
21
|
+
/** Deterministic, collision-resistant managed session name for a profile. */
|
|
22
|
+
export function tmuxSessionName(provider, profile) {
|
|
23
|
+
return `asw-${provider}-${profile}`;
|
|
24
|
+
}
|
|
25
|
+
export function readTmuxRegistry(file = TMUX_STATE) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
28
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function recordManagedSession(name, session, file = TMUX_STATE) {
|
|
35
|
+
const reg = readTmuxRegistry(file);
|
|
36
|
+
reg[name] = session;
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
38
|
+
fs.writeFileSync(file, JSON.stringify(reg, null, 2) + "\n", { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
export function forgetManagedSession(name, file = TMUX_STATE) {
|
|
41
|
+
const reg = readTmuxRegistry(file);
|
|
42
|
+
if (name in reg) {
|
|
43
|
+
delete reg[name];
|
|
44
|
+
fs.writeFileSync(file, JSON.stringify(reg, null, 2) + "\n", { mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// ---------- pure argv builders ----------------------------------------------
|
|
48
|
+
/** `tmux new-session -A -s <name> -e VAR=dir -- <cmd...>` — attach-or-create a
|
|
49
|
+
* named session with the profile env exported into it. */
|
|
50
|
+
export function newSessionArgs(name, envVar, dir, cmd) {
|
|
51
|
+
return ["new-session", "-A", "-s", name, "-e", `${envVar}=${dir}`, "--", ...cmd];
|
|
52
|
+
}
|
|
53
|
+
/** `tmux respawn-pane -k -t <target> -e VAR=dir -- <cmd...>` — replace the
|
|
54
|
+
* pane's process with the target profile's env + the resume command. `-k`
|
|
55
|
+
* reliably kills-and-replaces (the pane persists), which is why the in-place
|
|
56
|
+
* handoff uses this rather than a send-keys/close dance that would tear the
|
|
57
|
+
* pane down when the CLI is the pane's own command. */
|
|
58
|
+
export function respawnPaneArgs(target, envVar, dir, cmd) {
|
|
59
|
+
return ["respawn-pane", "-k", "-t", target, "-e", `${envVar}=${dir}`, "--", ...cmd];
|
|
60
|
+
}
|
|
61
|
+
/** `tmux send-keys -t <target> <literal> Enter` — type a command into a pane.
|
|
62
|
+
* Used by `compact` to type `/compact` into an agent-switch-MANAGED pane (the
|
|
63
|
+
* CLI is the pane's own process and stays running, so send-keys is right here,
|
|
64
|
+
* unlike takeover's respawn-pane). NEVER used on a non-managed pane. */
|
|
65
|
+
export function sendKeysArgs(target, literal) {
|
|
66
|
+
return ["send-keys", "-t", target, literal, "Enter"];
|
|
67
|
+
}
|
|
68
|
+
// ---------- environment detection --------------------------------------------
|
|
69
|
+
export function tmuxAvailable() {
|
|
70
|
+
if (process.platform === "win32")
|
|
71
|
+
return false;
|
|
72
|
+
return spawnSync("tmux", ["-V"], { stdio: "ignore" }).status === 0;
|
|
73
|
+
}
|
|
74
|
+
/** Inside a tmux pane? (`$TMUX` is set by tmux for every pane.) */
|
|
75
|
+
export function insideTmux(env = process.env) {
|
|
76
|
+
return typeof env.TMUX === "string" && env.TMUX.length > 0;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The managed session for the current pane, or null. Pure given the current
|
|
80
|
+
* session name + the registry — so `insideTmux` + a `tmux display-message`
|
|
81
|
+
* lookup feed it, and it is unit-testable without tmux.
|
|
82
|
+
*/
|
|
83
|
+
export function currentManagedSession(currentName, registry) {
|
|
84
|
+
if (!currentName)
|
|
85
|
+
return null;
|
|
86
|
+
return registry[currentName] ?? null;
|
|
87
|
+
}
|
|
88
|
+
/** The current pane's tmux session name (via `tmux display-message -p '#S'`),
|
|
89
|
+
* or null when not inside tmux / tmux missing. */
|
|
90
|
+
export function currentTmuxSessionName() {
|
|
91
|
+
if (!insideTmux())
|
|
92
|
+
return null;
|
|
93
|
+
const r = spawnSync("tmux", ["display-message", "-p", "#S"], { encoding: "utf8" });
|
|
94
|
+
if (r.status !== 0)
|
|
95
|
+
return null;
|
|
96
|
+
const s = r.stdout.trim();
|
|
97
|
+
return s.length ? s : null;
|
|
98
|
+
}
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token + cost reporting (roadmap: road-to-agent-switch-session-telemetry,
|
|
3
|
+
* Phase 5), DELEGATED to ccusage (council D2 — verified viable in spikes/t7).
|
|
4
|
+
* agent-switch does NOT re-implement transcript aggregation, dedup, or a
|
|
5
|
+
* pricing engine. It contributes two things ccusage cannot: (1) pointing
|
|
6
|
+
* ccusage at a specific profile's config dir, and (2) the cost-honesty label —
|
|
7
|
+
* agent-switch knows a profile is a subscription/OAuth login, so its cost is
|
|
8
|
+
* "notional" (API-equivalent), not real spend.
|
|
9
|
+
*
|
|
10
|
+
* ccusage is an OPTIONAL external tool (the tmux/playwright pattern): detected
|
|
11
|
+
* on PATH, never bundled. Absent → the caller prints an install pointer.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
/** The command `tokens install` runs (in the GUI's embedded terminal or a
|
|
15
|
+
* user's shell) to install ccusage globally. Fixed literal — never bundled as
|
|
16
|
+
* a dependency (zero-dep invariant + council D2). */
|
|
17
|
+
export const CCUSAGE_INSTALL = ["npm", "install", "-g", "ccusage"];
|
|
18
|
+
/** Resolve the ccusage runner. A PATH binary is preferred; the
|
|
19
|
+
* `AGENT_SWITCH_CCUSAGE` env var overrides with an explicit argv prefix
|
|
20
|
+
* (e.g. "npx -y ccusage@latest") for a zero-install run or tests. Null when
|
|
21
|
+
* neither is available. */
|
|
22
|
+
export function resolveCcusageRunner(env = process.env) {
|
|
23
|
+
const override = env.AGENT_SWITCH_CCUSAGE?.trim();
|
|
24
|
+
if (override)
|
|
25
|
+
return override.split(/\s+/);
|
|
26
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
27
|
+
const r = spawnSync(probe, ["ccusage"], { encoding: "utf8" });
|
|
28
|
+
if (r.status === 0 && (r.stdout || "").trim())
|
|
29
|
+
return ["ccusage"];
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** Parse ccusage `daily --json` output ({ daily: [...], totals: {...} }) into
|
|
33
|
+
* our shape. Pure + defensive: unknown/short shapes degrade to empty, never
|
|
34
|
+
* throw. `costBasis` is stamped by the caller (ccusage cannot know it). */
|
|
35
|
+
export function parseCcusageDaily(json, costBasis) {
|
|
36
|
+
const rows = Array.isArray(json?.daily) ? json.daily : Array.isArray(json) ? json : [];
|
|
37
|
+
const num = (v) => (Number.isFinite(v) ? v : 0);
|
|
38
|
+
const days = rows.map((r) => ({
|
|
39
|
+
date: typeof r?.period === "string" ? r.period : typeof r?.date === "string" ? r.date : "?",
|
|
40
|
+
inputTokens: num(r?.inputTokens),
|
|
41
|
+
outputTokens: num(r?.outputTokens),
|
|
42
|
+
cacheCreationTokens: num(r?.cacheCreationTokens),
|
|
43
|
+
cacheReadTokens: num(r?.cacheReadTokens),
|
|
44
|
+
totalTokens: num(r?.totalTokens),
|
|
45
|
+
cost: num(r?.totalCost),
|
|
46
|
+
models: Array.isArray(r?.modelsUsed) ? r.modelsUsed : [],
|
|
47
|
+
}));
|
|
48
|
+
const t = json?.totals ?? {};
|
|
49
|
+
const totals = {
|
|
50
|
+
inputTokens: num(t.inputTokens) || days.reduce((s, d) => s + d.inputTokens, 0),
|
|
51
|
+
outputTokens: num(t.outputTokens) || days.reduce((s, d) => s + d.outputTokens, 0),
|
|
52
|
+
cacheCreationTokens: num(t.cacheCreationTokens) || days.reduce((s, d) => s + d.cacheCreationTokens, 0),
|
|
53
|
+
cacheReadTokens: num(t.cacheReadTokens) || days.reduce((s, d) => s + d.cacheReadTokens, 0),
|
|
54
|
+
totalTokens: num(t.totalTokens) || days.reduce((s, d) => s + d.totalTokens, 0),
|
|
55
|
+
cost: num(t.totalCost) || days.reduce((s, d) => s + d.cost, 0),
|
|
56
|
+
};
|
|
57
|
+
return { days, totals, costBasis };
|
|
58
|
+
}
|
|
59
|
+
/** The cost basis for a profile. agent-switch profiles are OAuth/subscription
|
|
60
|
+
* logins → their token cost is NOTIONAL (API-equivalent, not real spend). A
|
|
61
|
+
* raw API key credential (sk-ant-…) would be `computed`. */
|
|
62
|
+
export function costBasisFor(rawCredential) {
|
|
63
|
+
// A raw API key → real, computed spend. Anything else (OAuth login blob, or
|
|
64
|
+
// unknown) → notional, the safe label that never overstates real spend.
|
|
65
|
+
if (rawCredential && /^sk-(ant|proj)-/.test(rawCredential.trim()))
|
|
66
|
+
return "computed";
|
|
67
|
+
return "notional";
|
|
68
|
+
}
|
|
69
|
+
/** Run ccusage for one profile's config dir and return the parsed report, or
|
|
70
|
+
* null when ccusage failed / produced no JSON (caller degrades gracefully). */
|
|
71
|
+
export function runCcusage(runner, provider, configDir, costBasis) {
|
|
72
|
+
const envKey = provider === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR";
|
|
73
|
+
const [cmd, ...prefix] = runner;
|
|
74
|
+
const r = spawnSync(cmd, [...prefix, "daily", "--json"], {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
timeout: 180_000,
|
|
77
|
+
env: { ...process.env, [envKey]: configDir },
|
|
78
|
+
});
|
|
79
|
+
const out = (r.stdout || "").trim();
|
|
80
|
+
if (!out)
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
return parseCcusageDaily(JSON.parse(out), costBasis);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage engine — policy-scoped, own-profile only.
|
|
3
|
+
*
|
|
4
|
+
* Parses Claude's OAuth `/usage` response into a defensive snapshot, formats it
|
|
5
|
+
* for `status`, tracks 30-day history, and detects active-profile threshold
|
|
6
|
+
* crossings. It NEVER compares or ranks accounts — that is the anti-rotation
|
|
7
|
+
* boundary the whole roadmap is locked around. Codex/Antigravity have no usage
|
|
8
|
+
* readout (verified), so they surface "usage unavailable" and never reach here.
|
|
9
|
+
*/
|
|
10
|
+
// The windows Claude's /usage exposes, richest first. seven_day_opus/sonnet are
|
|
11
|
+
// the per-model 7-day windows (extension-verified); absent shapes are skipped.
|
|
12
|
+
const WINDOW_DEFS = [
|
|
13
|
+
["five_hour", "5h"],
|
|
14
|
+
["seven_day", "7d"],
|
|
15
|
+
["seven_day_opus", "7d Opus"],
|
|
16
|
+
["seven_day_sonnet", "7d Sonnet"],
|
|
17
|
+
];
|
|
18
|
+
/** One entry of the current `limits[]` shape → a window, or null to skip. This
|
|
19
|
+
* array is the ONLY place the per-model weekly limit (e.g. Fable) is exposed;
|
|
20
|
+
* `weekly_all` is the all-models weekly window, `session` the 5h window. */
|
|
21
|
+
function windowFromLimit(l) {
|
|
22
|
+
if (!l || typeof l !== "object")
|
|
23
|
+
return null;
|
|
24
|
+
const utilization = typeof l.percent === "number" ? Math.round(l.percent) : null;
|
|
25
|
+
const resetsAt = typeof l.resets_at === "string" ? l.resets_at : null;
|
|
26
|
+
if (utilization === null && resetsAt === null)
|
|
27
|
+
return null;
|
|
28
|
+
const kind = typeof l.kind === "string" ? l.kind : "";
|
|
29
|
+
if (kind === "session")
|
|
30
|
+
return { key: "five_hour", label: "5h", utilization, resetsAt };
|
|
31
|
+
if (kind === "weekly_all")
|
|
32
|
+
return { key: "seven_day", label: "All", utilization, resetsAt };
|
|
33
|
+
if (kind === "weekly_scoped") {
|
|
34
|
+
const model = l.scope?.model?.display_name;
|
|
35
|
+
const surface = l.scope?.surface;
|
|
36
|
+
const name = (typeof model === "string" && model) || (typeof surface === "string" && surface) || "scoped";
|
|
37
|
+
const key = `weekly_${name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
|
|
38
|
+
return { key, label: name, utilization, resetsAt };
|
|
39
|
+
}
|
|
40
|
+
return { key: kind || "unknown", label: kind ? kind.replace(/_/g, " ") : "?", utilization, resetsAt };
|
|
41
|
+
}
|
|
42
|
+
/** Defensive parse — unknown shapes degrade to fewer windows, never throw.
|
|
43
|
+
* Prefers the current `limits[]` array (carries the per-model Fable window);
|
|
44
|
+
* falls back to the legacy top-level `five_hour`/`seven_day` keys. */
|
|
45
|
+
export function parseUsage(raw, capturedAt = new Date().toISOString()) {
|
|
46
|
+
const windows = [];
|
|
47
|
+
if (Array.isArray(raw?.limits) && raw.limits.length > 0) {
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const l of raw.limits) {
|
|
50
|
+
const w = windowFromLimit(l);
|
|
51
|
+
if (w && !seen.has(w.key)) {
|
|
52
|
+
seen.add(w.key);
|
|
53
|
+
windows.push(w);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (windows.length === 0) {
|
|
58
|
+
for (const [key, label] of WINDOW_DEFS) {
|
|
59
|
+
const w = raw?.[key];
|
|
60
|
+
if (!w || typeof w !== "object")
|
|
61
|
+
continue;
|
|
62
|
+
const utilization = typeof w.utilization === "number" ? Math.round(w.utilization) : null;
|
|
63
|
+
const resetsAt = typeof w.resets_at === "string" ? w.resets_at : null;
|
|
64
|
+
if (utilization === null && resetsAt === null)
|
|
65
|
+
continue;
|
|
66
|
+
windows.push({ key, label, utilization, resetsAt });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let routines = null;
|
|
70
|
+
const r = raw?.routines;
|
|
71
|
+
if (r && typeof r.used === "number" && typeof r.limit === "number") {
|
|
72
|
+
routines = { used: r.used, limit: r.limit };
|
|
73
|
+
}
|
|
74
|
+
return { windows, routines, capturedAt };
|
|
75
|
+
}
|
|
76
|
+
function resetHint(iso) {
|
|
77
|
+
const d = new Date(iso);
|
|
78
|
+
if (isNaN(d.getTime()))
|
|
79
|
+
return "";
|
|
80
|
+
return ` resets ${d.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" })}`;
|
|
81
|
+
}
|
|
82
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
83
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
84
|
+
/** Default minimum gap (fraction of the window) before pace is called either
|
|
85
|
+
* way — guards against noise around the on-track line. */
|
|
86
|
+
export const PACE_MIN_GAP = 0.05;
|
|
87
|
+
/** Window length for pace, derived from the key. The 5h window is deliberately
|
|
88
|
+
* excluded (too short/bursty to pace meaningfully); only weekly windows pace. */
|
|
89
|
+
function paceWindowMs(key) {
|
|
90
|
+
if (key === "five_hour")
|
|
91
|
+
return null;
|
|
92
|
+
if (key.startsWith("seven_day") || key.startsWith("weekly"))
|
|
93
|
+
return WEEK_MS;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* "Ahead of pace" = more of the window's quota consumed than of its cycle
|
|
98
|
+
* elapsed. PURE and stale-safe: time is measured against the snapshot's
|
|
99
|
+
* `capturedAt` (its real measurement instant), never the wall clock, so a
|
|
100
|
+
* re-served stale snapshot is judged against when it was actually taken. This
|
|
101
|
+
* is purely informational — it is NEVER an input to a switch decision (the
|
|
102
|
+
* anti-rotation boundary this file is locked around).
|
|
103
|
+
*
|
|
104
|
+
* Returns null when it cannot be judged: no utilization/reset, the 5h window,
|
|
105
|
+
* within 24h of a reset (suppressed — early-cycle noise), or a captured time
|
|
106
|
+
* outside the window's own cycle.
|
|
107
|
+
*/
|
|
108
|
+
export function windowPace(w, capturedAtIso, minGap = PACE_MIN_GAP) {
|
|
109
|
+
if (w.utilization === null || w.resetsAt === null)
|
|
110
|
+
return null;
|
|
111
|
+
const durMs = paceWindowMs(w.key);
|
|
112
|
+
if (durMs === null)
|
|
113
|
+
return null;
|
|
114
|
+
const reset = Date.parse(w.resetsAt);
|
|
115
|
+
const captured = Date.parse(capturedAtIso);
|
|
116
|
+
if (Number.isNaN(reset) || Number.isNaN(captured))
|
|
117
|
+
return null;
|
|
118
|
+
const cycleStart = reset - durMs;
|
|
119
|
+
const elapsedMs = captured - cycleStart;
|
|
120
|
+
if (elapsedMs < DAY_MS)
|
|
121
|
+
return null; // 24h post-reset suppression
|
|
122
|
+
if (elapsedMs <= 0 || elapsedMs > durMs)
|
|
123
|
+
return null; // outside this cycle
|
|
124
|
+
const aheadBy = w.utilization / 100 - elapsedMs / durMs;
|
|
125
|
+
if (aheadBy > minGap)
|
|
126
|
+
return "ahead";
|
|
127
|
+
if (aheadBy < -minGap)
|
|
128
|
+
return "behind";
|
|
129
|
+
return "on-track";
|
|
130
|
+
}
|
|
131
|
+
/** Human-readable lines for `status`; empty when nothing is known. */
|
|
132
|
+
export function formatSnapshot(s) {
|
|
133
|
+
const lines = [];
|
|
134
|
+
for (const w of s.windows) {
|
|
135
|
+
if (w.utilization === null)
|
|
136
|
+
continue;
|
|
137
|
+
// Surface only "ahead of pace" — the one informative signal; on-track /
|
|
138
|
+
// behind are the quiet default and add no line noise.
|
|
139
|
+
const pace = windowPace(w, s.capturedAt) === "ahead" ? " · ahead of pace" : "";
|
|
140
|
+
lines.push(` ${w.label}: ${String(w.utilization).padStart(3)}%${w.resetsAt ? resetHint(w.resetsAt) : ""}${pace}`);
|
|
141
|
+
}
|
|
142
|
+
if (s.routines)
|
|
143
|
+
lines.push(` routines: ${s.routines.used}/${s.routines.limit}`);
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
// ---------- headroom + auto-switch decision (pure) --------------------------
|
|
147
|
+
/** Highest utilization across a snapshot's windows (0-100), or null when no
|
|
148
|
+
* window reports a number. This is a profile's OWN headroom, never a ranking. */
|
|
149
|
+
export function maxUtilization(s) {
|
|
150
|
+
const vals = s.windows.map((w) => w.utilization).filter((u) => typeof u === "number");
|
|
151
|
+
return vals.length ? Math.max(...vals) : null;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Opt-in auto-switch decision (pure, no I/O). Given the active profile and the
|
|
155
|
+
* same-provider candidates with their usage, return the profile to switch to,
|
|
156
|
+
* or null. Switches only when the active profile has hit `threshold`% on some
|
|
157
|
+
* window AND another candidate has strictly more headroom (lower max
|
|
158
|
+
* utilization) while itself staying below `threshold`. Ties keep the first
|
|
159
|
+
* candidate. This is a single switch decision gated behind config — never a
|
|
160
|
+
* display ranking.
|
|
161
|
+
*/
|
|
162
|
+
export function pickSwitchTarget(active, candidates, threshold, isEligible = () => true) {
|
|
163
|
+
const activeSnap = candidates.find((c) => c.name === active)?.snapshot;
|
|
164
|
+
const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
|
|
165
|
+
if (activeMax === null || activeMax < threshold)
|
|
166
|
+
return null; // active still has headroom
|
|
167
|
+
let best = null;
|
|
168
|
+
for (const c of candidates) {
|
|
169
|
+
if (c.name === active)
|
|
170
|
+
continue;
|
|
171
|
+
if (!isEligible(c.name))
|
|
172
|
+
continue; // tag filter: only eligible accounts are switch targets
|
|
173
|
+
const m = maxUtilization(c.snapshot);
|
|
174
|
+
if (m === null || m >= threshold)
|
|
175
|
+
continue; // unknown or also maxed → not a target
|
|
176
|
+
if (!best || m < best.max)
|
|
177
|
+
best = { name: c.name, max: m };
|
|
178
|
+
}
|
|
179
|
+
return best?.name ?? null;
|
|
180
|
+
}
|
|
181
|
+
export const DEFAULT_THRESHOLDS = [75, 90];
|
|
182
|
+
/**
|
|
183
|
+
* Edge-triggered threshold detection: a threshold fires once when utilization
|
|
184
|
+
* first reaches it, and the fired-set resets when the window rolls over
|
|
185
|
+
* (`resetsAt` changes). Returns the crossings for THIS poll plus the new state
|
|
186
|
+
* to persist. Pure — no I/O, no cross-account view.
|
|
187
|
+
*/
|
|
188
|
+
export function detectCrossings(snapshot, prev, thresholds = DEFAULT_THRESHOLDS) {
|
|
189
|
+
const state = {};
|
|
190
|
+
const crossings = [];
|
|
191
|
+
for (const w of snapshot.windows) {
|
|
192
|
+
const prevW = prev[w.key];
|
|
193
|
+
if (w.utilization === null) {
|
|
194
|
+
state[w.key] = prevW ?? { resetsAt: w.resetsAt, fired: [] };
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// Same cycle → keep the fired set; rolled over → reset it.
|
|
198
|
+
const fired = prevW && prevW.resetsAt === w.resetsAt ? [...prevW.fired] : [];
|
|
199
|
+
for (const t of thresholds) {
|
|
200
|
+
if (w.utilization >= t && !fired.includes(t)) {
|
|
201
|
+
crossings.push({ window: w.key, threshold: t, utilization: w.utilization });
|
|
202
|
+
fired.push(t);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
state[w.key] = { resetsAt: w.resetsAt, fired };
|
|
206
|
+
}
|
|
207
|
+
return { crossings, state };
|
|
208
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@event4u/agent-switch",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Switch between multiple Claude Code accounts on macOS, Linux, and Windows via isolated CLAUDE_CONFIG_DIR profiles",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/event4u-app/agent-switch.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": {
|
|
11
|
+
"agent-switch": "dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"provenance": true
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"prepare": "tsc",
|
|
23
|
+
"test": "tsc -p tsconfig.test.json && cd dist-test && node --test"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.20.1",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
32
|
+
},
|
|
33
|
+
"optionalDependencies": {
|
|
34
|
+
"playwright": "^1.49.0",
|
|
35
|
+
"@event4u/agent-switch-darwin": "1.0.0",
|
|
36
|
+
"@event4u/agent-switch-linux-x64": "1.0.0",
|
|
37
|
+
"@event4u/agent-switch-win32-x64": "1.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|