@wbern/cc-ping 1.3.2 → 1.5.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.
Files changed (3) hide show
  1. package/README.md +48 -5
  2. package/dist/cli.js +255 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,10 +37,11 @@ npm install -g @wbern/cc-ping # also works
37
37
 
38
38
  ## Setup
39
39
 
40
- Discover accounts from `~/.claude-accounts/`, then verify they have valid credentials:
40
+ Discover accounts from `~` (or a custom directory), then verify they have valid credentials:
41
41
 
42
42
  ```bash
43
- cc-ping scan # auto-discover accounts
43
+ cc-ping scan # auto-discover accounts from ~
44
+ cc-ping scan /path/to/dir # scan a specific directory
44
45
  cc-ping check # verify credentials are valid
45
46
  cc-ping list # show configured accounts
46
47
  ```
@@ -98,7 +99,7 @@ Show which account has its quota window resetting soonest — useful for knowing
98
99
 
99
100
  ### `cc-ping scan`
100
101
 
101
- Auto-discover accounts from `~/.claude-accounts/`. Each subdirectory with a `claude_user.json` is detected as an account. Duplicate identities (same `accountUuid` across directories) are flagged.
102
+ Auto-discover Claude Code accounts. Scans `~` by default, or pass a directory to scan. Each subdirectory containing a `.claude.json` is detected as an account. Duplicate identities (same `accountUuid` across directories) are flagged.
102
103
 
103
104
  ### `cc-ping check`
104
105
 
@@ -141,6 +142,7 @@ cc-ping daemon stop # graceful shutdown
141
142
  | Flag | Default | Description |
142
143
  |------|---------|-------------|
143
144
  | `--interval <duration>` | `300m` | Time between ping cycles |
145
+ | `--smart-schedule <on\|off>` | `on` | Time pings based on your usage patterns |
144
146
  | `--notify` | `false` | Desktop notification when new windows open |
145
147
  | `--bell` | `false` | Terminal bell on failure |
146
148
  | `--quiet` | `true` | Suppress per-account output in logs |
@@ -148,12 +150,53 @@ cc-ping daemon stop # graceful shutdown
148
150
  The daemon is smart about what it pings:
149
151
 
150
152
  - **Skips active windows** — accounts with a quota window still running are skipped to avoid wasting pings
153
+ - **Retries failures** — if any accounts fail to ping, the daemon retries only the failed ones before sleeping
151
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
152
155
  - **Singleton enforcement** — only one daemon runs at a time, verified by PID and process name
153
- - **Graceful shutdown** — `daemon stop` writes a sentinel file and waits for a clean exit before force-killing
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
154
158
 
155
159
  Logs are written to `~/.config/cc-ping/daemon.log`.
156
160
 
161
+ ### Smart scheduling
162
+
163
+ By default, the daemon analyzes your Claude Code usage history to time pings optimally. The goal: your 5-hour quota window expires right when you're most active, not while you're asleep.
164
+
165
+ ```
166
+ Your typical day:
167
+
168
+ 12am 6am 12pm 6pm 12am
169
+ | | ________|________ | |
170
+ . . . . . | coding time | . . . .
171
+ ^
172
+ peak activity
173
+
174
+ Fixed interval -- ping fires whenever the timer says:
175
+
176
+ [======= 5h window =======]
177
+ 12am 5am
178
+ ^ expires while you sleep
179
+
180
+ Smart scheduling -- ping timed so window expires at peak:
181
+
182
+ [======= 5h window =======]
183
+ 8am 1pm
184
+ ^ expires while you code!
185
+ ```
186
+
187
+ **How it works:**
188
+
189
+ 1. Reads `<configDir>/history.jsonl` from each account's config directory (Claude Code's prompt timestamps)
190
+ 2. Builds an hour-of-day histogram from the last 14 days
191
+ 3. Finds the weighted midpoint of your activity
192
+ 4. Schedules pings at `midpoint - 5h` so the window expires at your peak
193
+
194
+ **Defer zone:** When smart scheduling is active, pings that would fire in the 5 hours before the optimal time are deferred. Pings outside this zone proceed normally for continuous coverage.
195
+
196
+ **Fallback:** If an account has fewer than 7 days of history or a flat usage pattern (no clear peak), smart scheduling is skipped and the fixed interval is used instead.
197
+
198
+ To disable: `cc-ping daemon start --smart-schedule off`
199
+
157
200
  ### System service (survive reboots)
158
201
 
159
202
  `daemon start` runs as a detached process that won't survive a reboot. Use `daemon install` to register as a system service that starts automatically on login:
@@ -216,7 +259,7 @@ Key design choices:
216
259
  - **Arithmetic prompts** — random math questions minimize token usage (~150 input tokens, ~10 output). Templates and operands are randomized to avoid cache hits across pings.
217
260
  - **Tools disabled** — `--tools ""` prevents the model from doing anything beyond answering the question.
218
261
  - **Single turn** — `--max-turns 1` ensures one request-response cycle, no follow-ups.
219
- - **30s timeout** — pings that take longer are killed.
262
+ - **30s timeout with hard kill** — pings that take longer are sent SIGKILL. A backstop timer force-resolves the promise even if the child process doesn't exit cleanly.
220
263
  - **Cost tracking** — each ping records its USD cost and token usage so you can audit spend.
221
264
 
222
265
  After a successful ping, the account's last-ping timestamp is saved to `~/.config/cc-ping/state.json`. The 5-hour quota window is calculated from this timestamp — commands like `status`, `suggest`, and the daemon all use it to determine window state.
package/dist/cli.js CHANGED
@@ -290,9 +290,9 @@ function sendNotification(title, body, opts) {
290
290
  const exec = opts?.exec ?? defaultExecFile;
291
291
  const cmd = buildNotifyCommand(title, body, platform, { sound: opts?.sound });
292
292
  if (!cmd) return Promise.resolve(false);
293
- return new Promise((resolve) => {
293
+ return new Promise((resolve2) => {
294
294
  exec(cmd[0], cmd[1], (error) => {
295
- resolve(!error);
295
+ resolve2(!error);
296
296
  });
297
297
  });
298
298
  }
@@ -386,7 +386,7 @@ function formatExecError(error) {
386
386
  }
387
387
  function pingOne(account) {
388
388
  const start = Date.now();
389
- return new Promise((resolve) => {
389
+ return new Promise((resolve2) => {
390
390
  let resolved = false;
391
391
  const child = execFile(
392
392
  "claude",
@@ -421,7 +421,7 @@ function pingOne(account) {
421
421
  } else if (isError) {
422
422
  errorMsg = claudeResponse?.subtype;
423
423
  }
424
- resolve({
424
+ resolve2({
425
425
  handle: account.handle,
426
426
  success: !error && !isError,
427
427
  durationMs: Date.now() - start,
@@ -437,7 +437,7 @@ function pingOne(account) {
437
437
  child.kill("SIGKILL");
438
438
  } catch {
439
439
  }
440
- resolve({
440
+ resolve2({
441
441
  handle: account.handle,
442
442
  success: false,
443
443
  durationMs: Date.now() - start,
@@ -488,7 +488,7 @@ async function runPing(accounts, options) {
488
488
  }
489
489
  }
490
490
  logger.log(`Pinging ${accounts.length} account(s)...`);
491
- const sleep = options._sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
491
+ const sleep = options._sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
492
492
  let results;
493
493
  if (options.staggerMs && options.staggerMs > 0 && accounts.length > 1) {
494
494
  results = [];
@@ -597,6 +597,101 @@ var init_run_ping = __esm({
597
597
  }
598
598
  });
599
599
 
600
+ // src/schedule.ts
601
+ var schedule_exports = {};
602
+ __export(schedule_exports, {
603
+ buildHourHistogram: () => buildHourHistogram,
604
+ findOptimalPingHour: () => findOptimalPingHour,
605
+ getAccountSchedule: () => getAccountSchedule,
606
+ parseSmartSchedule: () => parseSmartSchedule,
607
+ readAccountSchedule: () => readAccountSchedule,
608
+ shouldDefer: () => shouldDefer
609
+ });
610
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
611
+ import { join as join6 } from "path";
612
+ function buildHourHistogram(timestamps) {
613
+ const bins = new Array(24).fill(0);
614
+ for (const ts of timestamps) {
615
+ bins[ts.getUTCHours()]++;
616
+ }
617
+ return bins;
618
+ }
619
+ function findOptimalPingHour(histogram) {
620
+ const total = histogram.reduce((sum, v) => sum + v, 0);
621
+ if (total === 0) return -1;
622
+ const HOUR_TO_RAD = 2 * Math.PI / 24;
623
+ let sinSum = 0;
624
+ let cosSum = 0;
625
+ for (let h = 0; h < 24; h++) {
626
+ const angle = h * HOUR_TO_RAD;
627
+ sinSum += histogram[h] * Math.sin(angle);
628
+ cosSum += histogram[h] * Math.cos(angle);
629
+ }
630
+ const meanAngle = Math.atan2(sinSum, cosSum);
631
+ const midpoint = Math.round((meanAngle / HOUR_TO_RAD + 24) % 24);
632
+ return (midpoint - QUOTA_WINDOW_HOURS + 24) % 24;
633
+ }
634
+ function shouldDefer(now, optimalPingHour) {
635
+ const currentHour = now.getUTCHours();
636
+ const zoneStart = (optimalPingHour - QUOTA_WINDOW_HOURS + 24) % 24;
637
+ const inZone = zoneStart < optimalPingHour ? currentHour >= zoneStart && currentHour < optimalPingHour : currentHour >= zoneStart || currentHour < optimalPingHour;
638
+ if (inZone) {
639
+ return { defer: true, deferUntilUtcHour: optimalPingHour };
640
+ }
641
+ return { defer: false };
642
+ }
643
+ function getAccountSchedule(historyLines, now = /* @__PURE__ */ new Date()) {
644
+ const cutoff = now.getTime() - HISTORY_WINDOW_MS;
645
+ const timestamps = [];
646
+ const daysSeen = /* @__PURE__ */ new Set();
647
+ for (const line of historyLines) {
648
+ try {
649
+ const entry = JSON.parse(line);
650
+ if (typeof entry.timestamp !== "number") continue;
651
+ if (entry.timestamp < cutoff) continue;
652
+ const date = new Date(entry.timestamp);
653
+ timestamps.push(date);
654
+ daysSeen.add(date.toISOString().slice(0, 10));
655
+ } catch {
656
+ }
657
+ }
658
+ if (daysSeen.size < MIN_DAYS) return null;
659
+ const histogram = buildHourHistogram(timestamps);
660
+ const total = histogram.reduce((sum, v) => sum + v, 0);
661
+ const avg = total / 24;
662
+ const max = Math.max(...histogram);
663
+ if (max <= avg * 1.5) return null;
664
+ const optimalPingHour = findOptimalPingHour(histogram);
665
+ if (optimalPingHour === -1) return null;
666
+ return { optimalPingHour, histogram };
667
+ }
668
+ function readAccountSchedule(configDir, now = /* @__PURE__ */ new Date()) {
669
+ const historyPath = join6(configDir, "history.jsonl");
670
+ if (!existsSync5(historyPath)) return null;
671
+ const content = readFileSync5(historyPath, "utf-8");
672
+ const lines = content.split("\n").filter((l) => l.trim());
673
+ return getAccountSchedule(lines, now);
674
+ }
675
+ function parseSmartSchedule(value) {
676
+ const lower = value.toLowerCase();
677
+ if (TRUTHY.has(lower)) return true;
678
+ if (FALSY.has(lower)) return false;
679
+ throw new Error(
680
+ `Invalid smart-schedule value: "${value}". Use true/false, on/off, or 1/0`
681
+ );
682
+ }
683
+ var QUOTA_WINDOW_HOURS, MIN_DAYS, HISTORY_WINDOW_MS, TRUTHY, FALSY;
684
+ var init_schedule = __esm({
685
+ "src/schedule.ts"() {
686
+ "use strict";
687
+ QUOTA_WINDOW_HOURS = 5;
688
+ MIN_DAYS = 7;
689
+ HISTORY_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
690
+ TRUTHY = /* @__PURE__ */ new Set(["true", "on", "1"]);
691
+ FALSY = /* @__PURE__ */ new Set(["false", "off", "0"]);
692
+ }
693
+ });
694
+
600
695
  // src/daemon.ts
601
696
  var daemon_exports = {};
602
697
  __export(daemon_exports, {
@@ -617,23 +712,25 @@ __export(daemon_exports, {
617
712
  });
618
713
  import { execSync, spawn } from "child_process";
619
714
  import {
620
- existsSync as existsSync5,
715
+ existsSync as existsSync6,
621
716
  closeSync as fsCloseSync,
622
717
  openSync as fsOpenSync,
623
718
  mkdirSync as mkdirSync4,
624
- readFileSync as readFileSync5,
719
+ readFileSync as readFileSync6,
720
+ realpathSync,
721
+ statSync as statSync2,
625
722
  unlinkSync,
626
723
  writeFileSync as writeFileSync3
627
724
  } from "fs";
628
- import { join as join6 } from "path";
725
+ import { join as join7 } from "path";
629
726
  function daemonPidPath() {
630
- return join6(resolveConfigDir(), "daemon.json");
727
+ return join7(resolveConfigDir(), "daemon.json");
631
728
  }
632
729
  function daemonLogPath() {
633
- return join6(resolveConfigDir(), "daemon.log");
730
+ return join7(resolveConfigDir(), "daemon.log");
634
731
  }
635
732
  function daemonStopPath() {
636
- return join6(resolveConfigDir(), "daemon.stop");
733
+ return join7(resolveConfigDir(), "daemon.stop");
637
734
  }
638
735
  function writeDaemonState(state) {
639
736
  const configDir = resolveConfigDir();
@@ -643,9 +740,9 @@ function writeDaemonState(state) {
643
740
  }
644
741
  function readDaemonState() {
645
742
  const pidPath = daemonPidPath();
646
- if (!existsSync5(pidPath)) return null;
743
+ if (!existsSync6(pidPath)) return null;
647
744
  try {
648
- const raw = readFileSync5(pidPath, "utf-8");
745
+ const raw = readFileSync6(pidPath, "utf-8");
649
746
  return JSON.parse(raw);
650
747
  } catch {
651
748
  return null;
@@ -653,7 +750,7 @@ function readDaemonState() {
653
750
  }
654
751
  function removeDaemonState() {
655
752
  const pidPath = daemonPidPath();
656
- if (!existsSync5(pidPath)) return false;
753
+ if (!existsSync6(pidPath)) return false;
657
754
  unlinkSync(pidPath);
658
755
  return true;
659
756
  }
@@ -705,13 +802,17 @@ function getDaemonStatus(deps) {
705
802
  const nextPingMs = new Date(state.lastPingAt).getTime() + state.intervalMs - Date.now();
706
803
  nextPingIn = formatUptime(Math.max(0, nextPingMs));
707
804
  }
805
+ const currentVersion = deps?.currentVersion;
806
+ const versionMismatch = currentVersion != null && state.version != null ? state.version !== currentVersion : false;
708
807
  return {
709
808
  running: true,
710
809
  pid: state.pid,
711
810
  startedAt: state.startedAt,
712
811
  intervalMs: state.intervalMs,
713
812
  uptime,
714
- nextPingIn
813
+ nextPingIn,
814
+ versionMismatch,
815
+ daemonVersion: state.version
715
816
  };
716
817
  }
717
818
  function formatUptime(ms) {
@@ -726,12 +827,30 @@ function formatUptime(ms) {
726
827
  async function daemonLoop(intervalMs, options, deps) {
727
828
  let wakeDelayMs;
728
829
  while (!deps.shouldStop()) {
830
+ if (deps.hasUpgraded?.()) {
831
+ deps.log("Binary upgraded, exiting for restart...");
832
+ return "upgrade";
833
+ }
729
834
  const allAccounts = deps.listAccounts();
730
- const accounts = deps.isWindowActive ? allAccounts.filter((a) => !deps.isWindowActive(a.handle)) : allAccounts;
835
+ let accounts = deps.isWindowActive ? allAccounts.filter((a) => !deps.isWindowActive(a.handle)) : allAccounts;
731
836
  const skipped = allAccounts.length - accounts.length;
732
837
  if (skipped > 0) {
733
838
  deps.log(`Skipping ${skipped} account(s) with active window`);
734
839
  }
840
+ if (deps.shouldDeferPing) {
841
+ const deferResults = /* @__PURE__ */ new Map();
842
+ for (const a of accounts) {
843
+ deferResults.set(
844
+ a.handle,
845
+ deps.shouldDeferPing(a.handle, a.configDir).defer
846
+ );
847
+ }
848
+ const deferredCount = [...deferResults.values()].filter(Boolean).length;
849
+ if (deferredCount > 0) {
850
+ accounts = accounts.filter((a) => !deferResults.get(a.handle));
851
+ deps.log(`Deferring ${deferredCount} account(s) (smart scheduling)`);
852
+ }
853
+ }
735
854
  if (accounts.length === 0) {
736
855
  deps.log(
737
856
  allAccounts.length === 0 ? "No accounts configured, waiting..." : "All accounts have active windows, waiting..."
@@ -774,6 +893,7 @@ async function daemonLoop(intervalMs, options, deps) {
774
893
  wakeDelayMs = void 0;
775
894
  }
776
895
  }
896
+ return "stop";
777
897
  }
778
898
  function startDaemon(options, deps) {
779
899
  const _getDaemonStatus = deps?.getDaemonStatus ?? getDaemonStatus;
@@ -803,6 +923,7 @@ function startDaemon(options, deps) {
803
923
  if (options.quiet) args.push("--quiet");
804
924
  if (options.bell) args.push("--bell");
805
925
  if (options.notify) args.push("--notify");
926
+ if (options.smartSchedule === false) args.push("--smart-schedule", "off");
806
927
  const child = _spawn(process.execPath, [process.argv[1], ...args], {
807
928
  detached: true,
808
929
  stdio: ["ignore", logFd, logFd],
@@ -818,7 +939,8 @@ function startDaemon(options, deps) {
818
939
  pid: child.pid,
819
940
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
820
941
  intervalMs,
821
- configDir
942
+ configDir,
943
+ version: options.version
822
944
  });
823
945
  return { success: true, pid: child.pid };
824
946
  }
@@ -835,9 +957,9 @@ async function stopDaemon(deps) {
835
957
  const _removeStopFile = deps?.removeStopFile ?? /* c8 ignore next 4 -- production default */
836
958
  (() => {
837
959
  const stopPath = daemonStopPath();
838
- if (existsSync5(stopPath)) unlinkSync(stopPath);
960
+ if (existsSync6(stopPath)) unlinkSync(stopPath);
839
961
  });
840
- const _sleep = deps?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
962
+ const _sleep = deps?.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
841
963
  const _kill = deps?.kill ?? /* c8 ignore next 7 -- production default */
842
964
  ((pid2) => {
843
965
  if (process.platform === "win32") {
@@ -871,9 +993,9 @@ async function stopDaemon(deps) {
871
993
  }
872
994
  async function runDaemon(intervalMs, options, deps) {
873
995
  const stopPath = daemonStopPath();
874
- if (existsSync5(stopPath)) unlinkSync(stopPath);
996
+ if (existsSync6(stopPath)) unlinkSync(stopPath);
875
997
  const cleanup = () => {
876
- if (existsSync5(stopPath)) unlinkSync(stopPath);
998
+ if (existsSync6(stopPath)) unlinkSync(stopPath);
877
999
  removeDaemonState();
878
1000
  };
879
1001
  const onSigterm = () => {
@@ -889,27 +1011,63 @@ async function runDaemon(intervalMs, options, deps) {
889
1011
  deps.onSignal("SIGTERM", onSigterm);
890
1012
  deps.onSignal("SIGINT", onSigint);
891
1013
  deps.log(`Daemon started. Interval: ${Math.round(intervalMs / 6e4)}m`);
1014
+ let exitReason = "stop";
892
1015
  try {
893
- await daemonLoop(intervalMs, options, deps);
1016
+ exitReason = await daemonLoop(intervalMs, options, deps);
894
1017
  } finally {
895
1018
  deps.removeSignal("SIGTERM", onSigterm);
896
1019
  deps.removeSignal("SIGINT", onSigint);
897
1020
  deps.log("Daemon stopping...");
898
1021
  cleanup();
899
1022
  }
1023
+ if (exitReason === "upgrade") {
1024
+ deps.exit(75);
1025
+ }
900
1026
  }
901
1027
  async function runDaemonWithDefaults(intervalMs, options) {
902
1028
  const stopPath = daemonStopPath();
903
1029
  const { runPing: runPing2 } = await Promise.resolve().then(() => (init_run_ping(), run_ping_exports));
904
1030
  const { listAccounts: listAccounts2 } = await Promise.resolve().then(() => (init_config(), config_exports));
905
1031
  const { getWindowReset: getWindowReset2 } = await Promise.resolve().then(() => (init_state(), state_exports));
1032
+ const smartScheduleEnabled = options.smartSchedule !== false;
1033
+ let shouldDeferPing;
1034
+ if (smartScheduleEnabled) {
1035
+ const { readAccountSchedule: readAccountSchedule2, shouldDefer: shouldDefer2 } = await Promise.resolve().then(() => (init_schedule(), schedule_exports));
1036
+ for (const account of listAccounts2()) {
1037
+ const schedule = readAccountSchedule2(account.configDir);
1038
+ if (schedule) {
1039
+ console.log(
1040
+ `Smart schedule: ${account.handle} \u2192 optimal ping at ${schedule.optimalPingHour}:00 UTC`
1041
+ );
1042
+ } else {
1043
+ console.log(
1044
+ `Smart schedule: ${account.handle} \u2192 insufficient history, using fixed interval`
1045
+ );
1046
+ }
1047
+ }
1048
+ shouldDeferPing = (_handle, configDir) => {
1049
+ const schedule = readAccountSchedule2(configDir);
1050
+ if (!schedule) return { defer: false };
1051
+ return shouldDefer2(/* @__PURE__ */ new Date(), schedule.optimalPingHour);
1052
+ };
1053
+ }
1054
+ const binaryPath = realpathSync(process.argv[1]);
1055
+ const startMtimeMs = statSync2(binaryPath).mtimeMs;
906
1056
  await runDaemon(intervalMs, options, {
907
1057
  runPing: runPing2,
908
1058
  listAccounts: listAccounts2,
909
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
910
- shouldStop: () => existsSync5(stopPath),
1059
+ sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
1060
+ shouldStop: () => existsSync6(stopPath),
911
1061
  log: (msg) => console.log(msg),
912
1062
  isWindowActive: (handle) => getWindowReset2(handle) !== null,
1063
+ shouldDeferPing,
1064
+ hasUpgraded: () => {
1065
+ try {
1066
+ return statSync2(binaryPath).mtimeMs !== startMtimeMs;
1067
+ } catch {
1068
+ return false;
1069
+ }
1070
+ },
913
1071
  updateState: (patch) => {
914
1072
  const current = readDaemonState();
915
1073
  if (current) writeDaemonState({ ...current, ...patch });
@@ -950,7 +1108,7 @@ import {
950
1108
  writeFileSync as nodeWriteFileSync
951
1109
  } from "fs";
952
1110
  import { homedir as nodeHomedir } from "os";
953
- import { dirname, join as join9 } from "path";
1111
+ import { dirname, join as join10 } from "path";
954
1112
  function resolveExecutable(deps) {
955
1113
  try {
956
1114
  const path = deps.execSync("which cc-ping", {
@@ -979,10 +1137,12 @@ function generateLaunchdPlist(options, execInfo, configDir) {
979
1137
  if (options.quiet) programArgs.push("--quiet");
980
1138
  if (options.bell) programArgs.push("--bell");
981
1139
  if (options.notify) programArgs.push("--notify");
1140
+ if (options.smartSchedule === false)
1141
+ programArgs.push("--smart-schedule", "off");
982
1142
  const allArgs = [execInfo.executable, ...programArgs];
983
1143
  const argsXml = allArgs.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
984
- const logPath = join9(
985
- configDir || join9(nodeHomedir(), ".config", "cc-ping"),
1144
+ const logPath = join10(
1145
+ configDir || join10(nodeHomedir(), ".config", "cc-ping"),
986
1146
  "daemon.log"
987
1147
  );
988
1148
  let envSection = "";
@@ -1031,6 +1191,8 @@ function generateSystemdUnit(options, execInfo, configDir) {
1031
1191
  if (options.quiet) programArgs.push("--quiet");
1032
1192
  if (options.bell) programArgs.push("--bell");
1033
1193
  if (options.notify) programArgs.push("--notify");
1194
+ if (options.smartSchedule === false)
1195
+ programArgs.push("--smart-schedule", "off");
1034
1196
  const execStart = [execInfo.executable, ...programArgs].map((a) => a.includes(" ") ? `"${a}"` : a).join(" ");
1035
1197
  let envLine = "";
1036
1198
  if (configDir) {
@@ -1053,9 +1215,9 @@ WantedBy=default.target
1053
1215
  function servicePath(platform, home) {
1054
1216
  switch (platform) {
1055
1217
  case "darwin":
1056
- return join9(home, "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
1218
+ return join10(home, "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
1057
1219
  case "linux":
1058
- return join9(
1220
+ return join10(
1059
1221
  home,
1060
1222
  ".config",
1061
1223
  "systemd",
@@ -1201,7 +1363,7 @@ var init_service = __esm({
1201
1363
  });
1202
1364
 
1203
1365
  // src/cli.ts
1204
- import { basename } from "path";
1366
+ import { basename, resolve } from "path";
1205
1367
  import { Command } from "commander";
1206
1368
 
1207
1369
  // src/check.ts
@@ -1466,12 +1628,12 @@ init_daemon();
1466
1628
  init_config();
1467
1629
 
1468
1630
  // src/identity.ts
1469
- import { readFileSync as readFileSync6 } from "fs";
1470
- import { join as join7 } from "path";
1631
+ import { readFileSync as readFileSync7 } from "fs";
1632
+ import { join as join8 } from "path";
1471
1633
  function readAccountIdentity(configDir) {
1472
1634
  let raw;
1473
1635
  try {
1474
- raw = readFileSync6(join7(configDir, ".claude.json"), "utf-8");
1636
+ raw = readFileSync7(join8(configDir, ".claude.json"), "utf-8");
1475
1637
  } catch {
1476
1638
  return null;
1477
1639
  }
@@ -1662,21 +1824,24 @@ init_paths();
1662
1824
  init_run_ping();
1663
1825
 
1664
1826
  // src/scan.ts
1665
- import { existsSync as existsSync6, readdirSync, statSync as statSync2 } from "fs";
1827
+ import { existsSync as existsSync7, readdirSync, statSync as statSync3 } from "fs";
1666
1828
  import { homedir as homedir2 } from "os";
1667
- import { join as join8 } from "path";
1668
- var ACCOUNTS_DIR = join8(homedir2(), ".claude-accounts");
1669
- function scanAccounts() {
1670
- if (!existsSync6(ACCOUNTS_DIR)) return [];
1671
- return readdirSync(ACCOUNTS_DIR).filter((name) => {
1672
- const full = join8(ACCOUNTS_DIR, name);
1673
- return statSync2(full).isDirectory() && !name.startsWith(".");
1829
+ import { join as join9 } from "path";
1830
+ function scanAccounts(dir) {
1831
+ const accountsDir = dir ?? homedir2();
1832
+ if (!existsSync7(accountsDir)) return [];
1833
+ return readdirSync(accountsDir).filter((name) => {
1834
+ const full = join9(accountsDir, name);
1835
+ return statSync3(full).isDirectory() && !name.startsWith(".") && existsSync7(join9(full, ".claude.json"));
1674
1836
  }).map((name) => ({
1675
1837
  handle: name,
1676
- configDir: join8(ACCOUNTS_DIR, name)
1838
+ configDir: join9(accountsDir, name)
1677
1839
  }));
1678
1840
  }
1679
1841
 
1842
+ // src/cli.ts
1843
+ init_schedule();
1844
+
1680
1845
  // src/stagger.ts
1681
1846
  init_state();
1682
1847
  function calculateStagger(accountCount, windowMs = QUOTA_WINDOW_MS) {
@@ -1728,7 +1893,7 @@ function suggestAccount(accounts, now = /* @__PURE__ */ new Date()) {
1728
1893
  }
1729
1894
 
1730
1895
  // src/cli.ts
1731
- var program = new Command().name("cc-ping").description("Ping Claude Code sessions to trigger quota windows early").version("1.3.2").option(
1896
+ var program = new Command().name("cc-ping").description("Ping Claude Code sessions to trigger quota windows early").version("1.5.0").option(
1732
1897
  "--config <path>",
1733
1898
  "Path to config directory (default: ~/.config/cc-ping, env: CC_PING_CONFIG)"
1734
1899
  ).hook("preAction", (thisCommand) => {
@@ -1794,10 +1959,11 @@ program.command("check").description(
1794
1959
  process.exit(1);
1795
1960
  }
1796
1961
  });
1797
- program.command("scan").description("Auto-discover accounts from ~/.claude-accounts/").option("--dry-run", "Show what would be added without saving", false).action((opts) => {
1798
- const found = scanAccounts();
1962
+ program.command("scan").description("Auto-discover Claude Code accounts (scans ~ by default)").argument("[dir]", "Directory to scan (default: ~)").option("--dry-run", "Show what would be added without saving", false).action((dir, opts) => {
1963
+ const scanDir = dir ? resolve(dir) : void 0;
1964
+ const found = scanAccounts(scanDir);
1799
1965
  if (found.length === 0) {
1800
- console.log("No accounts found in ~/.claude-accounts/");
1966
+ console.log(`No accounts found in ${scanDir ?? "~"}`);
1801
1967
  return;
1802
1968
  }
1803
1969
  console.log(`Found ${found.length} account(s):`);
@@ -1934,12 +2100,21 @@ var daemon = program.command("daemon").description("Run auto-ping on a schedule"
1934
2100
  daemon.command("start").description("Start the daemon process").option(
1935
2101
  "--interval <minutes>",
1936
2102
  "Ping interval in minutes (default: 300 = 5h quota window)"
1937
- ).option("-q, --quiet", "Suppress ping output", false).option("--bell", "Ring terminal bell on ping failure", false).option("--notify", "Send desktop notification on ping failure", false).action(async (opts) => {
2103
+ ).option("-q, --quiet", "Suppress ping output", false).option("--bell", "Ring terminal bell on ping failure", false).option("--notify", "Send desktop notification on ping failure", false).option(
2104
+ "--smart-schedule <on|off>",
2105
+ "Time pings based on usage patterns (default: on)"
2106
+ ).action(async (opts) => {
2107
+ let smartSchedule;
2108
+ if (opts.smartSchedule !== void 0) {
2109
+ smartSchedule = parseSmartSchedule(opts.smartSchedule);
2110
+ }
1938
2111
  const result = startDaemon({
1939
2112
  interval: opts.interval,
1940
2113
  quiet: opts.quiet,
1941
2114
  bell: opts.bell,
1942
- notify: opts.notify
2115
+ notify: opts.notify,
2116
+ smartSchedule,
2117
+ version: "1.5.0"
1943
2118
  });
1944
2119
  if (!result.success) {
1945
2120
  console.error(result.error);
@@ -1973,7 +2148,7 @@ daemon.command("stop").description("Stop the daemon process").action(async () =>
1973
2148
  daemon.command("status").description("Show daemon status").option("--json", "Output as JSON", false).action(async (opts) => {
1974
2149
  const { getServiceStatus: getServiceStatus2 } = await Promise.resolve().then(() => (init_service(), service_exports));
1975
2150
  const svc = getServiceStatus2();
1976
- const status = getDaemonStatus();
2151
+ const status = getDaemonStatus({ currentVersion: "1.5.0" });
1977
2152
  if (opts.json) {
1978
2153
  const serviceInfo = svc.installed ? {
1979
2154
  service: {
@@ -2010,6 +2185,9 @@ daemon.command("status").description("Show daemon status").option("--json", "Out
2010
2185
  return;
2011
2186
  }
2012
2187
  console.log(`Daemon is running (PID: ${status.pid})`);
2188
+ if (status.daemonVersion) {
2189
+ console.log(` Version: ${status.daemonVersion}`);
2190
+ }
2013
2191
  console.log(` Started: ${status.startedAt}`);
2014
2192
  console.log(
2015
2193
  ` Interval: ${Math.round((status.intervalMs ?? 0) / 6e4)}m`
@@ -2022,19 +2200,35 @@ daemon.command("status").description("Show daemon status").option("--json", "Out
2022
2200
  const kind = svc.platform === "darwin" ? "launchd" : "systemd";
2023
2201
  console.log(` System service: installed (${kind})`);
2024
2202
  }
2203
+ if (status.versionMismatch) {
2204
+ console.log(
2205
+ ` Warning: daemon is running v${status.daemonVersion} but v${"1.5.0"} is installed.`
2206
+ );
2207
+ console.log(
2208
+ " Restart to pick up the new version: cc-ping daemon stop && cc-ping daemon start"
2209
+ );
2210
+ }
2025
2211
  console.log("");
2026
2212
  printAccountTable();
2027
2213
  });
2028
2214
  daemon.command("install").description("Install daemon as a system service (launchd/systemd)").option(
2029
2215
  "--interval <minutes>",
2030
2216
  "Ping interval in minutes (default: 300 = 5h quota window)"
2031
- ).option("-q, --quiet", "Suppress ping output", false).option("--bell", "Ring terminal bell on ping failure", false).option("--notify", "Send desktop notification on ping failure", false).action(async (opts) => {
2217
+ ).option("-q, --quiet", "Suppress ping output", false).option("--bell", "Ring terminal bell on ping failure", false).option("--notify", "Send desktop notification on ping failure", false).option(
2218
+ "--smart-schedule <on|off>",
2219
+ "Time pings based on usage patterns (default: on)"
2220
+ ).action(async (opts) => {
2221
+ let smartSchedule;
2222
+ if (opts.smartSchedule !== void 0) {
2223
+ smartSchedule = parseSmartSchedule(opts.smartSchedule);
2224
+ }
2032
2225
  const { installService: installService2 } = await Promise.resolve().then(() => (init_service(), service_exports));
2033
2226
  const result = await installService2({
2034
2227
  interval: opts.interval,
2035
2228
  quiet: opts.quiet,
2036
2229
  bell: opts.bell,
2037
- notify: opts.notify
2230
+ notify: opts.notify,
2231
+ smartSchedule
2038
2232
  });
2039
2233
  if (!result.success) {
2040
2234
  console.error(result.error);
@@ -2054,25 +2248,31 @@ daemon.command("uninstall").description("Remove daemon system service").action(a
2054
2248
  }
2055
2249
  console.log(`Service removed: ${result.servicePath}`);
2056
2250
  });
2057
- daemon.command("_run", { hidden: true }).option("--interval-ms <ms>", "Ping interval in milliseconds").option("-q, --quiet", "Suppress ping output", false).option("--bell", "Ring terminal bell on ping failure", false).option("--notify", "Send desktop notification on ping failure", false).action(async (opts) => {
2251
+ daemon.command("_run", { hidden: true }).option("--interval-ms <ms>", "Ping interval in milliseconds").option("-q, --quiet", "Suppress ping output", false).option("--bell", "Ring terminal bell on ping failure", false).option("--notify", "Send desktop notification on ping failure", false).option("--smart-schedule <on|off>", "Smart scheduling (default: on)").action(async (opts) => {
2058
2252
  const intervalMs = Number(opts.intervalMs);
2059
2253
  if (!intervalMs || intervalMs <= 0) {
2060
2254
  console.error("Invalid --interval-ms");
2061
2255
  process.exit(1);
2062
2256
  }
2257
+ let smartSchedule;
2258
+ if (opts.smartSchedule !== void 0) {
2259
+ smartSchedule = parseSmartSchedule(opts.smartSchedule);
2260
+ }
2063
2261
  if (!readDaemonState()) {
2064
2262
  const { resolveConfigDir: resolveConfigDir2 } = await Promise.resolve().then(() => (init_paths(), paths_exports));
2065
2263
  writeDaemonState({
2066
2264
  pid: process.pid,
2067
2265
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2068
2266
  intervalMs,
2069
- configDir: resolveConfigDir2()
2267
+ configDir: resolveConfigDir2(),
2268
+ version: "1.5.0"
2070
2269
  });
2071
2270
  }
2072
2271
  await runDaemonWithDefaults(intervalMs, {
2073
2272
  quiet: opts.quiet,
2074
2273
  bell: opts.bell,
2075
- notify: opts.notify
2274
+ notify: opts.notify,
2275
+ smartSchedule
2076
2276
  });
2077
2277
  });
2078
2278
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/cc-ping",
3
- "version": "1.3.2",
3
+ "version": "1.5.0",
4
4
  "description": "Ping Claude Code sessions to trigger quota windows early across multiple accounts",
5
5
  "type": "module",
6
6
  "bin": {