@wbern/cc-ping 1.3.2 → 1.4.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 +47 -5
  2. package/dist/cli.js +212 -50
  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,52 @@ 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
154
157
 
155
158
  Logs are written to `~/.config/cc-ping/daemon.log`.
156
159
 
160
+ ### Smart scheduling
161
+
162
+ 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.
163
+
164
+ ```
165
+ Your typical day:
166
+
167
+ 12am 6am 12pm 6pm 12am
168
+ | | ________|________ | |
169
+ . . . . . | coding time | . . . .
170
+ ^
171
+ peak activity
172
+
173
+ Fixed interval -- ping fires whenever the timer says:
174
+
175
+ [======= 5h window =======]
176
+ 12am 5am
177
+ ^ expires while you sleep
178
+
179
+ Smart scheduling -- ping timed so window expires at peak:
180
+
181
+ [======= 5h window =======]
182
+ 8am 1pm
183
+ ^ expires while you code!
184
+ ```
185
+
186
+ **How it works:**
187
+
188
+ 1. Reads `<configDir>/history.jsonl` from each account's config directory (Claude Code's prompt timestamps)
189
+ 2. Builds an hour-of-day histogram from the last 14 days
190
+ 3. Finds the weighted midpoint of your activity
191
+ 4. Schedules pings at `midpoint - 5h` so the window expires at your peak
192
+
193
+ **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.
194
+
195
+ **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.
196
+
197
+ To disable: `cc-ping daemon start --smart-schedule off`
198
+
157
199
  ### System service (survive reboots)
158
200
 
159
201
  `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 +258,7 @@ Key design choices:
216
258
  - **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
259
  - **Tools disabled** — `--tools ""` prevents the model from doing anything beyond answering the question.
218
260
  - **Single turn** — `--max-turns 1` ensures one request-response cycle, no follow-ups.
219
- - **30s timeout** — pings that take longer are killed.
261
+ - **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
262
  - **Cost tracking** — each ping records its USD cost and token usage so you can audit spend.
221
263
 
222
264
  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,23 @@ __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,
625
720
  unlinkSync,
626
721
  writeFileSync as writeFileSync3
627
722
  } from "fs";
628
- import { join as join6 } from "path";
723
+ import { join as join7 } from "path";
629
724
  function daemonPidPath() {
630
- return join6(resolveConfigDir(), "daemon.json");
725
+ return join7(resolveConfigDir(), "daemon.json");
631
726
  }
632
727
  function daemonLogPath() {
633
- return join6(resolveConfigDir(), "daemon.log");
728
+ return join7(resolveConfigDir(), "daemon.log");
634
729
  }
635
730
  function daemonStopPath() {
636
- return join6(resolveConfigDir(), "daemon.stop");
731
+ return join7(resolveConfigDir(), "daemon.stop");
637
732
  }
638
733
  function writeDaemonState(state) {
639
734
  const configDir = resolveConfigDir();
@@ -643,9 +738,9 @@ function writeDaemonState(state) {
643
738
  }
644
739
  function readDaemonState() {
645
740
  const pidPath = daemonPidPath();
646
- if (!existsSync5(pidPath)) return null;
741
+ if (!existsSync6(pidPath)) return null;
647
742
  try {
648
- const raw = readFileSync5(pidPath, "utf-8");
743
+ const raw = readFileSync6(pidPath, "utf-8");
649
744
  return JSON.parse(raw);
650
745
  } catch {
651
746
  return null;
@@ -653,7 +748,7 @@ function readDaemonState() {
653
748
  }
654
749
  function removeDaemonState() {
655
750
  const pidPath = daemonPidPath();
656
- if (!existsSync5(pidPath)) return false;
751
+ if (!existsSync6(pidPath)) return false;
657
752
  unlinkSync(pidPath);
658
753
  return true;
659
754
  }
@@ -727,11 +822,25 @@ async function daemonLoop(intervalMs, options, deps) {
727
822
  let wakeDelayMs;
728
823
  while (!deps.shouldStop()) {
729
824
  const allAccounts = deps.listAccounts();
730
- const accounts = deps.isWindowActive ? allAccounts.filter((a) => !deps.isWindowActive(a.handle)) : allAccounts;
825
+ let accounts = deps.isWindowActive ? allAccounts.filter((a) => !deps.isWindowActive(a.handle)) : allAccounts;
731
826
  const skipped = allAccounts.length - accounts.length;
732
827
  if (skipped > 0) {
733
828
  deps.log(`Skipping ${skipped} account(s) with active window`);
734
829
  }
830
+ if (deps.shouldDeferPing) {
831
+ const deferResults = /* @__PURE__ */ new Map();
832
+ for (const a of accounts) {
833
+ deferResults.set(
834
+ a.handle,
835
+ deps.shouldDeferPing(a.handle, a.configDir).defer
836
+ );
837
+ }
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)`);
842
+ }
843
+ }
735
844
  if (accounts.length === 0) {
736
845
  deps.log(
737
846
  allAccounts.length === 0 ? "No accounts configured, waiting..." : "All accounts have active windows, waiting..."
@@ -803,6 +912,7 @@ function startDaemon(options, deps) {
803
912
  if (options.quiet) args.push("--quiet");
804
913
  if (options.bell) args.push("--bell");
805
914
  if (options.notify) args.push("--notify");
915
+ if (options.smartSchedule === false) args.push("--smart-schedule", "off");
806
916
  const child = _spawn(process.execPath, [process.argv[1], ...args], {
807
917
  detached: true,
808
918
  stdio: ["ignore", logFd, logFd],
@@ -835,9 +945,9 @@ async function stopDaemon(deps) {
835
945
  const _removeStopFile = deps?.removeStopFile ?? /* c8 ignore next 4 -- production default */
836
946
  (() => {
837
947
  const stopPath = daemonStopPath();
838
- if (existsSync5(stopPath)) unlinkSync(stopPath);
948
+ if (existsSync6(stopPath)) unlinkSync(stopPath);
839
949
  });
840
- const _sleep = deps?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
950
+ const _sleep = deps?.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
841
951
  const _kill = deps?.kill ?? /* c8 ignore next 7 -- production default */
842
952
  ((pid2) => {
843
953
  if (process.platform === "win32") {
@@ -871,9 +981,9 @@ async function stopDaemon(deps) {
871
981
  }
872
982
  async function runDaemon(intervalMs, options, deps) {
873
983
  const stopPath = daemonStopPath();
874
- if (existsSync5(stopPath)) unlinkSync(stopPath);
984
+ if (existsSync6(stopPath)) unlinkSync(stopPath);
875
985
  const cleanup = () => {
876
- if (existsSync5(stopPath)) unlinkSync(stopPath);
986
+ if (existsSync6(stopPath)) unlinkSync(stopPath);
877
987
  removeDaemonState();
878
988
  };
879
989
  const onSigterm = () => {
@@ -903,13 +1013,36 @@ async function runDaemonWithDefaults(intervalMs, options) {
903
1013
  const { runPing: runPing2 } = await Promise.resolve().then(() => (init_run_ping(), run_ping_exports));
904
1014
  const { listAccounts: listAccounts2 } = await Promise.resolve().then(() => (init_config(), config_exports));
905
1015
  const { getWindowReset: getWindowReset2 } = await Promise.resolve().then(() => (init_state(), state_exports));
1016
+ const smartScheduleEnabled = options.smartSchedule !== false;
1017
+ let shouldDeferPing;
1018
+ if (smartScheduleEnabled) {
1019
+ const { readAccountSchedule: readAccountSchedule2, shouldDefer: shouldDefer2 } = await Promise.resolve().then(() => (init_schedule(), schedule_exports));
1020
+ for (const account of listAccounts2()) {
1021
+ const schedule = readAccountSchedule2(account.configDir);
1022
+ if (schedule) {
1023
+ console.log(
1024
+ `Smart schedule: ${account.handle} \u2192 optimal ping at ${schedule.optimalPingHour}:00 UTC`
1025
+ );
1026
+ } else {
1027
+ console.log(
1028
+ `Smart schedule: ${account.handle} \u2192 insufficient history, using fixed interval`
1029
+ );
1030
+ }
1031
+ }
1032
+ shouldDeferPing = (_handle, configDir) => {
1033
+ const schedule = readAccountSchedule2(configDir);
1034
+ if (!schedule) return { defer: false };
1035
+ return shouldDefer2(/* @__PURE__ */ new Date(), schedule.optimalPingHour);
1036
+ };
1037
+ }
906
1038
  await runDaemon(intervalMs, options, {
907
1039
  runPing: runPing2,
908
1040
  listAccounts: listAccounts2,
909
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
910
- shouldStop: () => existsSync5(stopPath),
1041
+ sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
1042
+ shouldStop: () => existsSync6(stopPath),
911
1043
  log: (msg) => console.log(msg),
912
1044
  isWindowActive: (handle) => getWindowReset2(handle) !== null,
1045
+ shouldDeferPing,
913
1046
  updateState: (patch) => {
914
1047
  const current = readDaemonState();
915
1048
  if (current) writeDaemonState({ ...current, ...patch });
@@ -950,7 +1083,7 @@ import {
950
1083
  writeFileSync as nodeWriteFileSync
951
1084
  } from "fs";
952
1085
  import { homedir as nodeHomedir } from "os";
953
- import { dirname, join as join9 } from "path";
1086
+ import { dirname, join as join10 } from "path";
954
1087
  function resolveExecutable(deps) {
955
1088
  try {
956
1089
  const path = deps.execSync("which cc-ping", {
@@ -979,10 +1112,12 @@ function generateLaunchdPlist(options, execInfo, configDir) {
979
1112
  if (options.quiet) programArgs.push("--quiet");
980
1113
  if (options.bell) programArgs.push("--bell");
981
1114
  if (options.notify) programArgs.push("--notify");
1115
+ if (options.smartSchedule === false)
1116
+ programArgs.push("--smart-schedule", "off");
982
1117
  const allArgs = [execInfo.executable, ...programArgs];
983
1118
  const argsXml = allArgs.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
984
- const logPath = join9(
985
- configDir || join9(nodeHomedir(), ".config", "cc-ping"),
1119
+ const logPath = join10(
1120
+ configDir || join10(nodeHomedir(), ".config", "cc-ping"),
986
1121
  "daemon.log"
987
1122
  );
988
1123
  let envSection = "";
@@ -1031,6 +1166,8 @@ function generateSystemdUnit(options, execInfo, configDir) {
1031
1166
  if (options.quiet) programArgs.push("--quiet");
1032
1167
  if (options.bell) programArgs.push("--bell");
1033
1168
  if (options.notify) programArgs.push("--notify");
1169
+ if (options.smartSchedule === false)
1170
+ programArgs.push("--smart-schedule", "off");
1034
1171
  const execStart = [execInfo.executable, ...programArgs].map((a) => a.includes(" ") ? `"${a}"` : a).join(" ");
1035
1172
  let envLine = "";
1036
1173
  if (configDir) {
@@ -1053,9 +1190,9 @@ WantedBy=default.target
1053
1190
  function servicePath(platform, home) {
1054
1191
  switch (platform) {
1055
1192
  case "darwin":
1056
- return join9(home, "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
1193
+ return join10(home, "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
1057
1194
  case "linux":
1058
- return join9(
1195
+ return join10(
1059
1196
  home,
1060
1197
  ".config",
1061
1198
  "systemd",
@@ -1201,7 +1338,7 @@ var init_service = __esm({
1201
1338
  });
1202
1339
 
1203
1340
  // src/cli.ts
1204
- import { basename } from "path";
1341
+ import { basename, resolve } from "path";
1205
1342
  import { Command } from "commander";
1206
1343
 
1207
1344
  // src/check.ts
@@ -1466,12 +1603,12 @@ init_daemon();
1466
1603
  init_config();
1467
1604
 
1468
1605
  // src/identity.ts
1469
- import { readFileSync as readFileSync6 } from "fs";
1470
- import { join as join7 } from "path";
1606
+ import { readFileSync as readFileSync7 } from "fs";
1607
+ import { join as join8 } from "path";
1471
1608
  function readAccountIdentity(configDir) {
1472
1609
  let raw;
1473
1610
  try {
1474
- raw = readFileSync6(join7(configDir, ".claude.json"), "utf-8");
1611
+ raw = readFileSync7(join8(configDir, ".claude.json"), "utf-8");
1475
1612
  } catch {
1476
1613
  return null;
1477
1614
  }
@@ -1662,21 +1799,24 @@ init_paths();
1662
1799
  init_run_ping();
1663
1800
 
1664
1801
  // src/scan.ts
1665
- import { existsSync as existsSync6, readdirSync, statSync as statSync2 } from "fs";
1802
+ import { existsSync as existsSync7, readdirSync, statSync as statSync2 } from "fs";
1666
1803
  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(".");
1804
+ import { join as join9 } from "path";
1805
+ function scanAccounts(dir) {
1806
+ const accountsDir = dir ?? homedir2();
1807
+ if (!existsSync7(accountsDir)) return [];
1808
+ return readdirSync(accountsDir).filter((name) => {
1809
+ const full = join9(accountsDir, name);
1810
+ return statSync2(full).isDirectory() && !name.startsWith(".") && existsSync7(join9(full, ".claude.json"));
1674
1811
  }).map((name) => ({
1675
1812
  handle: name,
1676
- configDir: join8(ACCOUNTS_DIR, name)
1813
+ configDir: join9(accountsDir, name)
1677
1814
  }));
1678
1815
  }
1679
1816
 
1817
+ // src/cli.ts
1818
+ init_schedule();
1819
+
1680
1820
  // src/stagger.ts
1681
1821
  init_state();
1682
1822
  function calculateStagger(accountCount, windowMs = QUOTA_WINDOW_MS) {
@@ -1728,7 +1868,7 @@ function suggestAccount(accounts, now = /* @__PURE__ */ new Date()) {
1728
1868
  }
1729
1869
 
1730
1870
  // 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(
1871
+ var program = new Command().name("cc-ping").description("Ping Claude Code sessions to trigger quota windows early").version("1.4.0").option(
1732
1872
  "--config <path>",
1733
1873
  "Path to config directory (default: ~/.config/cc-ping, env: CC_PING_CONFIG)"
1734
1874
  ).hook("preAction", (thisCommand) => {
@@ -1794,10 +1934,11 @@ program.command("check").description(
1794
1934
  process.exit(1);
1795
1935
  }
1796
1936
  });
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();
1937
+ 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) => {
1938
+ const scanDir = dir ? resolve(dir) : void 0;
1939
+ const found = scanAccounts(scanDir);
1799
1940
  if (found.length === 0) {
1800
- console.log("No accounts found in ~/.claude-accounts/");
1941
+ console.log(`No accounts found in ${scanDir ?? "~"}`);
1801
1942
  return;
1802
1943
  }
1803
1944
  console.log(`Found ${found.length} account(s):`);
@@ -1934,12 +2075,20 @@ var daemon = program.command("daemon").description("Run auto-ping on a schedule"
1934
2075
  daemon.command("start").description("Start the daemon process").option(
1935
2076
  "--interval <minutes>",
1936
2077
  "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) => {
2078
+ ).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(
2079
+ "--smart-schedule <on|off>",
2080
+ "Time pings based on usage patterns (default: on)"
2081
+ ).action(async (opts) => {
2082
+ let smartSchedule;
2083
+ if (opts.smartSchedule !== void 0) {
2084
+ smartSchedule = parseSmartSchedule(opts.smartSchedule);
2085
+ }
1938
2086
  const result = startDaemon({
1939
2087
  interval: opts.interval,
1940
2088
  quiet: opts.quiet,
1941
2089
  bell: opts.bell,
1942
- notify: opts.notify
2090
+ notify: opts.notify,
2091
+ smartSchedule
1943
2092
  });
1944
2093
  if (!result.success) {
1945
2094
  console.error(result.error);
@@ -2028,13 +2177,21 @@ daemon.command("status").description("Show daemon status").option("--json", "Out
2028
2177
  daemon.command("install").description("Install daemon as a system service (launchd/systemd)").option(
2029
2178
  "--interval <minutes>",
2030
2179
  "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) => {
2180
+ ).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(
2181
+ "--smart-schedule <on|off>",
2182
+ "Time pings based on usage patterns (default: on)"
2183
+ ).action(async (opts) => {
2184
+ let smartSchedule;
2185
+ if (opts.smartSchedule !== void 0) {
2186
+ smartSchedule = parseSmartSchedule(opts.smartSchedule);
2187
+ }
2032
2188
  const { installService: installService2 } = await Promise.resolve().then(() => (init_service(), service_exports));
2033
2189
  const result = await installService2({
2034
2190
  interval: opts.interval,
2035
2191
  quiet: opts.quiet,
2036
2192
  bell: opts.bell,
2037
- notify: opts.notify
2193
+ notify: opts.notify,
2194
+ smartSchedule
2038
2195
  });
2039
2196
  if (!result.success) {
2040
2197
  console.error(result.error);
@@ -2054,12 +2211,16 @@ daemon.command("uninstall").description("Remove daemon system service").action(a
2054
2211
  }
2055
2212
  console.log(`Service removed: ${result.servicePath}`);
2056
2213
  });
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) => {
2214
+ 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
2215
  const intervalMs = Number(opts.intervalMs);
2059
2216
  if (!intervalMs || intervalMs <= 0) {
2060
2217
  console.error("Invalid --interval-ms");
2061
2218
  process.exit(1);
2062
2219
  }
2220
+ let smartSchedule;
2221
+ if (opts.smartSchedule !== void 0) {
2222
+ smartSchedule = parseSmartSchedule(opts.smartSchedule);
2223
+ }
2063
2224
  if (!readDaemonState()) {
2064
2225
  const { resolveConfigDir: resolveConfigDir2 } = await Promise.resolve().then(() => (init_paths(), paths_exports));
2065
2226
  writeDaemonState({
@@ -2072,7 +2233,8 @@ daemon.command("_run", { hidden: true }).option("--interval-ms <ms>", "Ping inte
2072
2233
  await runDaemonWithDefaults(intervalMs, {
2073
2234
  quiet: opts.quiet,
2074
2235
  bell: opts.bell,
2075
- notify: opts.notify
2236
+ notify: opts.notify,
2237
+ smartSchedule
2076
2238
  });
2077
2239
  });
2078
2240
  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.4.0",
4
4
  "description": "Ping Claude Code sessions to trigger quota windows early across multiple accounts",
5
5
  "type": "module",
6
6
  "bin": {