election-vibe 0.1.0 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/dist/cli.js +92 -16
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -9,7 +9,7 @@ Unofficial results for the US general election of November 3, 2026, in your term
9
9
  You need Node 22 or later.
10
10
 
11
11
  ```bash
12
- npm i -g election-vibe # installs the `elections` command
12
+ npm i -g election-vibe # installs the `elections` command (also as `election-vibe`)
13
13
  elections collect # the collector: leave it running in its own terminal
14
14
  ```
15
15
 
@@ -302,6 +302,8 @@ To remove it all, in this order (the plugin lives inside the package's folder, s
302
302
 
303
303
  **Does collection stop on November 4?** No: the collector runs until you stop it. November 4 is how long the tool is supported; see [Operating window](#operating-window).
304
304
 
305
+ **Why does `collect --once` take so long?** It is not stuck. `--once` reads everything that is due, once, then exits. On a fresh install that is every contest the collector reads from civicAPI (471, plus one for each followed California contest that civicAPI also carries), one request at a time at about one per second, so the single pass takes about 10 minutes and prints its one log line at the end. Plain `elections collect` paces itself (at most 9 civicAPI requests per pass, then a 10-second wait before the next pass) and is the normal way to run it. For a quick connectivity check, `elections collect --once --civic-limit 2` bounds the pass to two civicAPI requests and exits in seconds.
306
+
305
307
  **Does this run on Windows?** Not tested. This release was built and tested on macOS. The CLI uses only Node, but the plugin assumes a macOS or Linux shell path, and nothing here has been tried on Windows or Linux.
306
308
 
307
309
  **Does `race` treat the comma as part of the query?** Yes. `race` takes its words as one phrase, punctuation included, and looks for it in titles and ids: `"palo alto, transactions"` matches Palo Alto's Measure J because its title contains that phrase. `find` is the one that matches word by word.
@@ -310,6 +312,8 @@ To remove it all, in this order (the plugin lives inside the package's folder, s
310
312
 
311
313
  **Which Claude Code version?** The plugin was tested with Claude Code 2.1.316. To update: update the package, restart the collector, then `/reload-plugins`. To remove: the plugin before the package (see above).
312
314
 
315
+ **Does it work behind a corporate proxy?** It connects to the sources directly and does not use a proxy (`HTTPS_PROXY` is not read). On a network that inspects HTTPS or blocks direct connections, the reads fail, and `elections sources` says why for each source (for example "the connection's certificate is not trusted").
316
+
313
317
  **How do I pick the display time zone?** `--tz <IANA zone>` on any command, or `ELECTIONS_TZ`. The default is your computer's zone.
314
318
 
315
319
  **How much disk does it use?** A few megabytes for the snapshots, plus one log line per collector cycle.
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { appendFileSync } from "fs";
4
+ import { appendFileSync, realpathSync } from "fs";
5
+ import { fileURLToPath } from "url";
5
6
 
6
7
  // src/store.ts
7
8
  import { randomBytes } from "crypto";
@@ -215,7 +216,7 @@ var REHEARSAL_SENTENCE = {
215
216
 
216
217
  // src/http.ts
217
218
  import { gunzipSync } from "zlib";
218
- var USER_AGENT = `ElectionsCompanion/${"0.1.0"} (unofficial results reader; local, no telemetry)`;
219
+ var USER_AGENT = `ElectionsCompanion/${"0.1.2"} (unofficial results reader; local, no telemetry)`;
219
220
  function retryAfterMs(header, nowMs) {
220
221
  if (!header) return void 0;
221
222
  const t = header.trim();
@@ -295,6 +296,9 @@ async function fetchDocument(url, opts) {
295
296
  }
296
297
  }
297
298
  if (res.status === 401 || res.status === 403) return { kind: "denied", status: res.status, fetchedAt: stamp() };
299
+ if (res.status === 407) {
300
+ return { kind: "client-error", status: 407, fetchedAt: stamp(), detail: "HTTP 407: a proxy asks for credentials; this tool connects directly and does not use a proxy" };
301
+ }
298
302
  if (res.status >= 500) return { kind: "server-error", status: res.status, fetchedAt: stamp() };
299
303
  if (res.status !== 200) return { kind: "client-error", status: res.status, fetchedAt: stamp() };
300
304
  let body = opts.method === "HEAD" ? Buffer.alloc(0) : await readBounded2(res, maxBytes);
@@ -319,11 +323,23 @@ async function fetchDocument(url, opts) {
319
323
  } catch (err) {
320
324
  if (err instanceof BodyTooLargeError) return { kind: "too-large", status: err.status, fetchedAt: stamp(), detail: "captured body over bound" };
321
325
  const aborted = err.name === "AbortError";
322
- return { kind: aborted ? "timeout" : "network", fetchedAt: stamp(), detail: aborted ? void 0 : String(err.message ?? err).slice(0, 120) };
326
+ return { kind: aborted ? "timeout" : "network", fetchedAt: stamp(), detail: aborted ? void 0 : networkWords(err) };
323
327
  } finally {
324
328
  clearTimeout(timer);
325
329
  }
326
330
  }
331
+ function networkWords(err) {
332
+ const raw = err?.cause?.code;
333
+ const code = typeof raw === "string" && /^[A-Z][A-Z0-9_]{1,39}$/.test(raw) ? raw : "";
334
+ if (/^(SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_(GET_ISSUER_CERT(_LOCALLY)?|VERIFY_LEAF_SIGNATURE)|CERT_[A-Z_]+|ERR_TLS_[A-Z_]+)$/.test(code)) {
335
+ return `the connection's certificate is not trusted (${code}): a network that inspects HTTPS, such as a corporate proxy, causes this; this tool connects directly`;
336
+ }
337
+ if (/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/.test(code)) {
338
+ return `the connection was refused or cut off (${code}): no network, or a firewall or proxy that blocks direct connections`;
339
+ }
340
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") return `the source's name could not be resolved (${code}): no network, or a network that blocks the name`;
341
+ return code ? `the connection failed (${code})` : "the connection failed";
342
+ }
327
343
  function jsonOf(body) {
328
344
  try {
329
345
  return JSON.parse(body.toString("utf8"));
@@ -1927,6 +1943,8 @@ var CIVIC_BLANKET_FLOOR_SEC = 900;
1927
1943
  var THROTTLE_FLOOR_MS = 5 * 6e4;
1928
1944
  var BACKOFF_MAX_MS = 30 * 6e4;
1929
1945
  var MAX_EVENTS = 2e3;
1946
+ var CLOSE_SPREAD_MS = 6e4;
1947
+ var READ_JITTER_SHARE = 0.1;
1930
1948
  var CLARITY_RECHECK_MS = 30 * 6e4;
1931
1949
  var LockLost = class extends Error {
1932
1950
  constructor(report) {
@@ -1985,10 +2003,40 @@ function newerRecord(a, b) {
1985
2003
  function blocked(s, nowMs) {
1986
2004
  return !!s.deniedAt || !!s.cooldownUntil && Date.parse(s.cooldownUntil) > nowMs;
1987
2005
  }
1988
- function sourceCadenceMs(src, contests, nowMs, rehearsal = false) {
2006
+ var closeOffsets = /* @__PURE__ */ new WeakMap();
2007
+ function closeOffsetMs(deps, id) {
2008
+ let run = closeOffsets.get(deps);
2009
+ if (!run) {
2010
+ run = /* @__PURE__ */ new Map();
2011
+ closeOffsets.set(deps, run);
2012
+ }
2013
+ let o = run.get(id);
2014
+ if (o === void 0) {
2015
+ const r = deps.random();
2016
+ o = r >= 0 && r < 1 ? Math.floor(r * CLOSE_SPREAD_MS) : 0;
2017
+ run.set(id, o);
2018
+ }
2019
+ return o;
2020
+ }
2021
+ var readJitters = /* @__PURE__ */ new WeakMap();
2022
+ function readJitterMs(deps, id, after, cadenceMs) {
2023
+ let run = readJitters.get(deps);
2024
+ if (!run) {
2025
+ run = /* @__PURE__ */ new Map();
2026
+ readJitters.set(deps, run);
2027
+ }
2028
+ let held = run.get(id);
2029
+ if (!held || held.after !== after) {
2030
+ const r = deps.random();
2031
+ held = { after, share: r >= 0 && r < 1 ? r : 0 };
2032
+ run.set(id, held);
2033
+ }
2034
+ return Math.floor(held.share * READ_JITTER_SHARE * cadenceMs);
2035
+ }
2036
+ function sourceCadenceMs(src, contests, nowMs, rehearsal, offsetMs, jitterMs) {
1989
2037
  if (rehearsal) return src.cadenceSec * 1e3;
1990
- const anyClosed = contests.some((c) => c.sources.some((m2) => m2.source === src.id) && Date.parse(c.pollsClose) <= nowMs);
1991
- return (anyClosed ? src.cadenceSec : src.preCloseCadenceSec) * 1e3;
2038
+ const anyClosed = contests.some((c) => c.sources.some((m2) => m2.source === src.id) && Date.parse(c.pollsClose) + offsetMs <= nowMs);
2039
+ return anyClosed ? src.cadenceSec * 1e3 + jitterMs() : src.preCloseCadenceSec * 1e3;
1992
2040
  }
1993
2041
  function due(s, cadenceMs, nowMs) {
1994
2042
  if (blocked(s, nowMs)) return false;
@@ -2123,7 +2171,7 @@ function sealSources(raw) {
2123
2171
  }
2124
2172
  return out;
2125
2173
  }
2126
- function civicQueue(registry, src, config, last, nowMs) {
2174
+ function civicQueue(registry, src, config, last, nowMs, closeOffsetMs2 = 0, jitterMs = () => 0) {
2127
2175
  const blanketSec = Math.max(CIVIC_BLANKET_FLOOR_SEC, config.blanketSec ?? Number(src.config.blanketSec ?? 1800));
2128
2176
  const out = [];
2129
2177
  for (const def of registry) {
@@ -2131,10 +2179,10 @@ function civicQueue(registry, src, config, last, nowMs) {
2131
2179
  if (!m2) continue;
2132
2180
  const watch = config.follow.includes(def.id) || (def.presets ?? []).includes("hot");
2133
2181
  if (m2.role !== "primary" && !watch) continue;
2134
- const preClose = nowMs < Date.parse(def.pollsClose);
2182
+ const preClose = nowMs < Date.parse(def.pollsClose) + closeOffsetMs2;
2135
2183
  const cadence = (preClose ? src.preCloseCadenceSec : watch ? src.cadenceSec : blanketSec) * 1e3;
2136
2184
  const prev = last[m2.key];
2137
- if (prev !== void 0 && nowMs - prev < cadence) continue;
2185
+ if (prev !== void 0 && nowMs - prev < cadence + (preClose ? 0 : jitterMs(m2.key, prev, cadence))) continue;
2138
2186
  out.push({ def, key: m2.key, tier: watch ? "watch" : "blanket", age: prev === void 0 ? Number.POSITIVE_INFINITY : nowMs - prev });
2139
2187
  }
2140
2188
  out.sort((a, b) => a.tier === b.tier ? b.age - a.age : a.tier === "watch" ? -1 : 1);
@@ -2223,7 +2271,15 @@ async function cycle(deps, opts) {
2223
2271
  if (src.kind === "civicapi") continue;
2224
2272
  const approval = approvals[src.id];
2225
2273
  const approvedSinceAttempt = !!approval && !!s.lastAttemptAt && Date.parse(approval.approvedAt) > Date.parse(s.lastAttemptAt);
2226
- if (!(approvedSinceAttempt && !blocked(s, t0)) && !due(s, sourceCadenceMs(src, deps.registry, t0, opts.admissionDisabled === true), t0)) {
2274
+ const cadenceMs = sourceCadenceMs(
2275
+ src,
2276
+ deps.registry,
2277
+ t0,
2278
+ opts.admissionDisabled === true,
2279
+ closeOffsetMs(deps, src.id),
2280
+ () => readJitterMs(deps, src.id, s.lastAttemptAt, src.cadenceSec * 1e3)
2281
+ );
2282
+ if (!(approvedSinceAttempt && !blocked(s, t0)) && !due(s, cadenceMs, t0)) {
2227
2283
  report.outcomes[src.id] = "skipped";
2228
2284
  continue;
2229
2285
  }
@@ -2481,7 +2537,15 @@ async function cycle(deps, opts) {
2481
2537
  const civic = deps.sources.find((s) => s.kind === "civicapi");
2482
2538
  if (civic && !config.disabledSources.includes(civic.id)) {
2483
2539
  const minInterval = Number(civic.config.minIntervalMs ?? 1100);
2484
- const queue = civicQueue(deps.registry, civic, config, cstate.civicLast, deps.now());
2540
+ const queue = civicQueue(
2541
+ deps.registry,
2542
+ civic,
2543
+ config,
2544
+ cstate.civicLast,
2545
+ deps.now(),
2546
+ opts.admissionDisabled === true ? 0 : closeOffsetMs(deps, civic.id),
2547
+ opts.admissionDisabled === true ? void 0 : (key, prev, cadenceMs) => readJitterMs(deps, `civic:${key}`, prev, cadenceMs)
2548
+ );
2485
2549
  const limit = opts.civicLimit ?? queue.length;
2486
2550
  let n = 0;
2487
2551
  for (const item of queue) {
@@ -2492,7 +2556,9 @@ async function cycle(deps, opts) {
2492
2556
  log("civicapi: hourly ceiling reached; the rest waits");
2493
2557
  break;
2494
2558
  }
2495
- if (n > 0) await deps.sleep(minInterval + Math.round(deps.random() * 250));
2559
+ const draw = deps.random();
2560
+ const spread = draw >= 0 && draw < 1 ? Math.round(draw * 250) : 0;
2561
+ await deps.sleep(n > 0 ? minInterval + spread : spread);
2496
2562
  n++;
2497
2563
  cstate.civicLast[item.key] = deps.now();
2498
2564
  const r = await get(civic, `${civic.config.base}/race/${item.key}`);
@@ -46269,7 +46335,7 @@ function storeOf(p) {
46269
46335
  }
46270
46336
  async function main(argv, io) {
46271
46337
  if (argv[0] === "--version" || argv[0] === "version") {
46272
- io.out("0.1.0");
46338
+ io.out("0.1.2");
46273
46339
  return 0;
46274
46340
  }
46275
46341
  const p = parseArgs(argv);
@@ -46476,6 +46542,8 @@ ${renderRace(v, { nowMs, zone, width, color, partisan, pollsClose })}`);
46476
46542
  disabled: cfg.disabledSources.includes(s.id),
46477
46543
  approved: approvals[s.id] ?? null,
46478
46544
  lastOutcome: st?.lastOutcome ?? null,
46545
+ // Why the last read failed, in words (a blocked network names what blocks it); null after a success.
46546
+ lastOutcomeDetail: st?.lastOutcome && st.lastOutcome !== "ok" && st.lastOutcome !== "not-modified" && st.lastOutcomeDetail ? humanLabel(st.lastOutcomeDetail, 300) : null,
46479
46547
  lastSuccessAt: st?.lastSuccessAt ?? null,
46480
46548
  cooldownUntil: st?.cooldownUntil ?? null,
46481
46549
  deniedAt: st?.deniedAt ?? null,
@@ -46492,7 +46560,7 @@ ${renderRace(v, { nowMs, zone, width, color, partisan, pollsClose })}`);
46492
46560
  const lines = rows.map(
46493
46561
  (r) => [
46494
46562
  `${r.name} (${r.id}) \xB7 ledger ${r.ledger} \xB7 admission ${r.admission}${r.disabled ? " \xB7 DISABLED locally" : ""}`,
46495
- ` last: ${r.lastOutcome ?? "never read"}${r.lastSuccessAt ? ` \xB7 last success ${formatAge(r.lastSuccessAt, nowMs)} ago` : ""}${r.cooldownUntil && Date.parse(r.cooldownUntil) > nowMs ? ` \xB7 cooling down until ${formatInstant(r.cooldownUntil, zone)}` : ""}${r.deniedAt ? " \xB7 ACCESS DENIED: stopped pending diagnosis" : ""}`,
46563
+ ` last: ${r.lastOutcome ?? "never read"}${r.lastOutcomeDetail ? `: ${r.lastOutcomeDetail}` : ""}${r.lastSuccessAt ? ` \xB7 last success ${formatAge(r.lastSuccessAt, nowMs)} ago` : ""}${r.cooldownUntil && Date.parse(r.cooldownUntil) > nowMs ? ` \xB7 cooling down until ${formatInstant(r.cooldownUntil, zone)}` : ""}${r.deniedAt ? " \xB7 ACCESS DENIED: stopped pending diagnosis" : ""}`,
46496
46564
  ` revision: ${r.lastRevision ? `${localizeTimes(r.lastRevision.label, zone)} (hash ${r.lastRevision.hash})` : "none"} \xB7 production: ${r.approved ? `approved from ${approvalPoint(r.id, r.approved.revisionOrder, zone)}, at ${formatInstant(r.approved.approvedAt, zone)}` : r.admission === "operator" ? "awaiting production approval" : "admitted by source tag after poll close"}`,
46497
46565
  ` requests this hour: ${r.requestsThisHour} \xB7 ${r.officialLink ? `official results: ${r.officialLink}` : `not an official source (aggregator): ${r.site}`}`
46498
46566
  ].join("\n")
@@ -46720,8 +46788,15 @@ function approvalPoint(sourceId, order, zone) {
46720
46788
  const src = sourceById(sourceId);
46721
46789
  return src && orderIsTime(src) ? `the report of ${formatInstant(new Date(order).toISOString(), zone)}` : `revision ${order}`;
46722
46790
  }
46723
- var isEntry = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("/elections") || process.argv[1]?.endsWith("cli.js");
46724
- if (isEntry) {
46791
+ function isEntryPath(launched, moduleUrl) {
46792
+ if (!launched) return false;
46793
+ try {
46794
+ return realpathSync(launched) === realpathSync(fileURLToPath(moduleUrl));
46795
+ } catch {
46796
+ return false;
46797
+ }
46798
+ }
46799
+ if (isEntryPath(process.argv[1], import.meta.url)) {
46725
46800
  main(process.argv.slice(2), {
46726
46801
  out: (s) => process.stdout.write(`${s}
46727
46802
  `),
@@ -46751,6 +46826,7 @@ function exitWhenFlushed(code) {
46751
46826
  process.stderr.write("", done);
46752
46827
  }
46753
46828
  export {
46829
+ isEntryPath,
46754
46830
  main,
46755
46831
  parseArgs
46756
46832
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "election-vibe",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "US election night, Nov 3, 2026, in your terminal and in Claude Code: unofficial results from official free sources and civicAPI, kept on your machine. Never calls a race. Unofficial results. Not an election authority. Not affiliated with any government, campaign, or party.",
5
5
  "keywords": [
6
6
  "election",
@@ -19,7 +19,8 @@
19
19
  "node": ">=22"
20
20
  },
21
21
  "bin": {
22
- "elections": "./dist/cli.js"
22
+ "elections": "./dist/cli.js",
23
+ "election-vibe": "./dist/cli.js"
23
24
  },
24
25
  "files": [
25
26
  "dist",