@wbern/cc-ping 1.4.0 → 1.5.1

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 +1 -0
  2. package/dist/cli.js +81 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -154,6 +154,7 @@ The daemon is smart about what it pings:
154
154
  - **Detects system sleep** — if the machine wakes from sleep and a ping cycle is overdue, the daemon notices and factors the delay into notifications
155
155
  - **Singleton enforcement** — only one daemon runs at a time, verified by PID and process name
156
156
  - **Graceful shutdown** — `daemon stop` writes a sentinel file and waits up to 60s for a clean exit before force-killing
157
+ - **Auto-restart on upgrade** — after upgrading cc-ping, the daemon detects the binary has changed and exits so the service manager can restart it with the new version. `daemon status` warns if the running daemon is outdated
157
158
 
158
159
  Logs are written to `~/.config/cc-ping/daemon.log`.
159
160
 
package/dist/cli.js CHANGED
@@ -701,6 +701,7 @@ __export(daemon_exports, {
701
701
  daemonStopPath: () => daemonStopPath,
702
702
  getDaemonStatus: () => getDaemonStatus,
703
703
  isProcessRunning: () => isProcessRunning,
704
+ msUntilUtcHour: () => msUntilUtcHour,
704
705
  parseInterval: () => parseInterval,
705
706
  readDaemonState: () => readDaemonState,
706
707
  removeDaemonState: () => removeDaemonState,
@@ -717,6 +718,8 @@ import {
717
718
  openSync as fsOpenSync,
718
719
  mkdirSync as mkdirSync4,
719
720
  readFileSync as readFileSync6,
721
+ realpathSync,
722
+ statSync as statSync2,
720
723
  unlinkSync,
721
724
  writeFileSync as writeFileSync3
722
725
  } from "fs";
@@ -800,13 +803,17 @@ function getDaemonStatus(deps) {
800
803
  const nextPingMs = new Date(state.lastPingAt).getTime() + state.intervalMs - Date.now();
801
804
  nextPingIn = formatUptime(Math.max(0, nextPingMs));
802
805
  }
806
+ const currentVersion = deps?.currentVersion;
807
+ const versionMismatch = currentVersion != null && state.version != null ? state.version !== currentVersion : false;
803
808
  return {
804
809
  running: true,
805
810
  pid: state.pid,
806
811
  startedAt: state.startedAt,
807
812
  intervalMs: state.intervalMs,
808
813
  uptime,
809
- nextPingIn
814
+ nextPingIn,
815
+ versionMismatch,
816
+ daemonVersion: state.version
810
817
  };
811
818
  }
812
819
  function formatUptime(ms) {
@@ -818,32 +825,47 @@ function formatUptime(ms) {
818
825
  if (minutes > 0) return `${minutes}m ${seconds}s`;
819
826
  return `${seconds}s`;
820
827
  }
828
+ function msUntilUtcHour(targetHour, now) {
829
+ const currentMs = now.getUTCHours() * 36e5 + now.getUTCMinutes() * 6e4 + now.getUTCSeconds() * 1e3 + now.getUTCMilliseconds();
830
+ const targetMs = targetHour * 36e5;
831
+ const diff = targetMs - currentMs;
832
+ return diff > 0 ? diff : diff + 24 * 36e5;
833
+ }
821
834
  async function daemonLoop(intervalMs, options, deps) {
822
835
  let wakeDelayMs;
823
836
  while (!deps.shouldStop()) {
837
+ if (deps.hasUpgraded?.()) {
838
+ deps.log("Binary upgraded, exiting for restart...");
839
+ return "upgrade";
840
+ }
824
841
  const allAccounts = deps.listAccounts();
825
842
  let accounts = deps.isWindowActive ? allAccounts.filter((a) => !deps.isWindowActive(a.handle)) : allAccounts;
826
843
  const skipped = allAccounts.length - accounts.length;
827
844
  if (skipped > 0) {
828
845
  deps.log(`Skipping ${skipped} account(s) with active window`);
829
846
  }
847
+ let soonestDeferHour;
830
848
  if (deps.shouldDeferPing) {
831
849
  const deferResults = /* @__PURE__ */ new Map();
832
850
  for (const a of accounts) {
833
- deferResults.set(
834
- a.handle,
835
- deps.shouldDeferPing(a.handle, a.configDir).defer
836
- );
851
+ deferResults.set(a.handle, deps.shouldDeferPing(a.handle, a.configDir));
837
852
  }
838
- const deferredCount = [...deferResults.values()].filter(Boolean).length;
839
- if (deferredCount > 0) {
840
- accounts = accounts.filter((a) => !deferResults.get(a.handle));
841
- deps.log(`Deferring ${deferredCount} account(s) (smart scheduling)`);
853
+ const deferred = [...deferResults.entries()].filter(([, r]) => r.defer);
854
+ if (deferred.length > 0) {
855
+ accounts = accounts.filter((a) => !deferResults.get(a.handle)?.defer);
856
+ deps.log(`Deferring ${deferred.length} account(s) (smart scheduling)`);
857
+ for (const [, r] of deferred) {
858
+ if (r.deferUntilUtcHour !== void 0 && (soonestDeferHour === void 0 || /* c8 ignore next -- production default */
859
+ msUntilUtcHour(r.deferUntilUtcHour, deps.now?.() ?? /* @__PURE__ */ new Date()) < /* c8 ignore next -- production default */
860
+ msUntilUtcHour(soonestDeferHour, deps.now?.() ?? /* @__PURE__ */ new Date()))) {
861
+ soonestDeferHour = r.deferUntilUtcHour;
862
+ }
863
+ }
842
864
  }
843
865
  }
844
866
  if (accounts.length === 0) {
845
867
  deps.log(
846
- allAccounts.length === 0 ? "No accounts configured, waiting..." : "All accounts have active windows, waiting..."
868
+ allAccounts.length === 0 ? "No accounts configured, waiting..." : soonestDeferHour !== void 0 ? "All accounts deferred (smart scheduling), waiting..." : "All accounts have active windows, waiting..."
847
869
  );
848
870
  } else {
849
871
  deps.log(
@@ -872,9 +894,20 @@ async function daemonLoop(intervalMs, options, deps) {
872
894
  deps.updateState?.({ lastPingAt: (/* @__PURE__ */ new Date()).toISOString() });
873
895
  }
874
896
  if (deps.shouldStop()) break;
875
- deps.log(`Sleeping ${Math.round(intervalMs / 6e4)}m until next ping...`);
897
+ let sleepMs = intervalMs;
898
+ if (soonestDeferHour !== void 0) {
899
+ const msUntilDefer = msUntilUtcHour(
900
+ soonestDeferHour,
901
+ /* c8 ignore next -- production default */
902
+ deps.now?.() ?? /* @__PURE__ */ new Date()
903
+ );
904
+ if (msUntilDefer > 0 && msUntilDefer < intervalMs) {
905
+ sleepMs = msUntilDefer;
906
+ }
907
+ }
908
+ deps.log(`Sleeping ${Math.round(sleepMs / 6e4)}m until next ping...`);
876
909
  const sleepStart = Date.now();
877
- await deps.sleep(intervalMs);
910
+ await deps.sleep(sleepMs);
878
911
  const overshootMs = Date.now() - sleepStart - intervalMs;
879
912
  if (overshootMs > 6e4) {
880
913
  wakeDelayMs = overshootMs;
@@ -883,6 +916,7 @@ async function daemonLoop(intervalMs, options, deps) {
883
916
  wakeDelayMs = void 0;
884
917
  }
885
918
  }
919
+ return "stop";
886
920
  }
887
921
  function startDaemon(options, deps) {
888
922
  const _getDaemonStatus = deps?.getDaemonStatus ?? getDaemonStatus;
@@ -928,7 +962,8 @@ function startDaemon(options, deps) {
928
962
  pid: child.pid,
929
963
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
930
964
  intervalMs,
931
- configDir
965
+ configDir,
966
+ version: options.version
932
967
  });
933
968
  return { success: true, pid: child.pid };
934
969
  }
@@ -999,14 +1034,18 @@ async function runDaemon(intervalMs, options, deps) {
999
1034
  deps.onSignal("SIGTERM", onSigterm);
1000
1035
  deps.onSignal("SIGINT", onSigint);
1001
1036
  deps.log(`Daemon started. Interval: ${Math.round(intervalMs / 6e4)}m`);
1037
+ let exitReason = "stop";
1002
1038
  try {
1003
- await daemonLoop(intervalMs, options, deps);
1039
+ exitReason = await daemonLoop(intervalMs, options, deps);
1004
1040
  } finally {
1005
1041
  deps.removeSignal("SIGTERM", onSigterm);
1006
1042
  deps.removeSignal("SIGINT", onSigint);
1007
1043
  deps.log("Daemon stopping...");
1008
1044
  cleanup();
1009
1045
  }
1046
+ if (exitReason === "upgrade") {
1047
+ deps.exit(75);
1048
+ }
1010
1049
  }
1011
1050
  async function runDaemonWithDefaults(intervalMs, options) {
1012
1051
  const stopPath = daemonStopPath();
@@ -1035,6 +1074,8 @@ async function runDaemonWithDefaults(intervalMs, options) {
1035
1074
  return shouldDefer2(/* @__PURE__ */ new Date(), schedule.optimalPingHour);
1036
1075
  };
1037
1076
  }
1077
+ const binaryPath = realpathSync(process.argv[1]);
1078
+ const startMtimeMs = statSync2(binaryPath).mtimeMs;
1038
1079
  await runDaemon(intervalMs, options, {
1039
1080
  runPing: runPing2,
1040
1081
  listAccounts: listAccounts2,
@@ -1043,6 +1084,13 @@ async function runDaemonWithDefaults(intervalMs, options) {
1043
1084
  log: (msg) => console.log(msg),
1044
1085
  isWindowActive: (handle) => getWindowReset2(handle) !== null,
1045
1086
  shouldDeferPing,
1087
+ hasUpgraded: () => {
1088
+ try {
1089
+ return statSync2(binaryPath).mtimeMs !== startMtimeMs;
1090
+ } catch {
1091
+ return false;
1092
+ }
1093
+ },
1046
1094
  updateState: (patch) => {
1047
1095
  const current = readDaemonState();
1048
1096
  if (current) writeDaemonState({ ...current, ...patch });
@@ -1799,7 +1847,7 @@ init_paths();
1799
1847
  init_run_ping();
1800
1848
 
1801
1849
  // src/scan.ts
1802
- import { existsSync as existsSync7, readdirSync, statSync as statSync2 } from "fs";
1850
+ import { existsSync as existsSync7, readdirSync, statSync as statSync3 } from "fs";
1803
1851
  import { homedir as homedir2 } from "os";
1804
1852
  import { join as join9 } from "path";
1805
1853
  function scanAccounts(dir) {
@@ -1807,7 +1855,7 @@ function scanAccounts(dir) {
1807
1855
  if (!existsSync7(accountsDir)) return [];
1808
1856
  return readdirSync(accountsDir).filter((name) => {
1809
1857
  const full = join9(accountsDir, name);
1810
- return statSync2(full).isDirectory() && !name.startsWith(".") && existsSync7(join9(full, ".claude.json"));
1858
+ return statSync3(full).isDirectory() && !name.startsWith(".") && existsSync7(join9(full, ".claude.json"));
1811
1859
  }).map((name) => ({
1812
1860
  handle: name,
1813
1861
  configDir: join9(accountsDir, name)
@@ -1868,7 +1916,7 @@ function suggestAccount(accounts, now = /* @__PURE__ */ new Date()) {
1868
1916
  }
1869
1917
 
1870
1918
  // src/cli.ts
1871
- var program = new Command().name("cc-ping").description("Ping Claude Code sessions to trigger quota windows early").version("1.4.0").option(
1919
+ var program = new Command().name("cc-ping").description("Ping Claude Code sessions to trigger quota windows early").version("1.5.1").option(
1872
1920
  "--config <path>",
1873
1921
  "Path to config directory (default: ~/.config/cc-ping, env: CC_PING_CONFIG)"
1874
1922
  ).hook("preAction", (thisCommand) => {
@@ -2088,7 +2136,8 @@ daemon.command("start").description("Start the daemon process").option(
2088
2136
  quiet: opts.quiet,
2089
2137
  bell: opts.bell,
2090
2138
  notify: opts.notify,
2091
- smartSchedule
2139
+ smartSchedule,
2140
+ version: "1.5.1"
2092
2141
  });
2093
2142
  if (!result.success) {
2094
2143
  console.error(result.error);
@@ -2122,7 +2171,7 @@ daemon.command("stop").description("Stop the daemon process").action(async () =>
2122
2171
  daemon.command("status").description("Show daemon status").option("--json", "Output as JSON", false).action(async (opts) => {
2123
2172
  const { getServiceStatus: getServiceStatus2 } = await Promise.resolve().then(() => (init_service(), service_exports));
2124
2173
  const svc = getServiceStatus2();
2125
- const status = getDaemonStatus();
2174
+ const status = getDaemonStatus({ currentVersion: "1.5.1" });
2126
2175
  if (opts.json) {
2127
2176
  const serviceInfo = svc.installed ? {
2128
2177
  service: {
@@ -2159,6 +2208,9 @@ daemon.command("status").description("Show daemon status").option("--json", "Out
2159
2208
  return;
2160
2209
  }
2161
2210
  console.log(`Daemon is running (PID: ${status.pid})`);
2211
+ if (status.daemonVersion) {
2212
+ console.log(` Version: ${status.daemonVersion}`);
2213
+ }
2162
2214
  console.log(` Started: ${status.startedAt}`);
2163
2215
  console.log(
2164
2216
  ` Interval: ${Math.round((status.intervalMs ?? 0) / 6e4)}m`
@@ -2171,6 +2223,14 @@ daemon.command("status").description("Show daemon status").option("--json", "Out
2171
2223
  const kind = svc.platform === "darwin" ? "launchd" : "systemd";
2172
2224
  console.log(` System service: installed (${kind})`);
2173
2225
  }
2226
+ if (status.versionMismatch) {
2227
+ console.log(
2228
+ ` Warning: daemon is running v${status.daemonVersion} but v${"1.5.1"} is installed.`
2229
+ );
2230
+ console.log(
2231
+ " Restart to pick up the new version: cc-ping daemon stop && cc-ping daemon start"
2232
+ );
2233
+ }
2174
2234
  console.log("");
2175
2235
  printAccountTable();
2176
2236
  });
@@ -2227,7 +2287,8 @@ daemon.command("_run", { hidden: true }).option("--interval-ms <ms>", "Ping inte
2227
2287
  pid: process.pid,
2228
2288
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2229
2289
  intervalMs,
2230
- configDir: resolveConfigDir2()
2290
+ configDir: resolveConfigDir2(),
2291
+ version: "1.5.1"
2231
2292
  });
2232
2293
  }
2233
2294
  await runDaemonWithDefaults(intervalMs, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/cc-ping",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Ping Claude Code sessions to trigger quota windows early across multiple accounts",
5
5
  "type": "module",
6
6
  "bin": {