@wbern/cc-ping 1.3.1 → 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 +258 -59
  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,8 @@ 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
+ let resolved = false;
390
391
  const child = execFile(
391
392
  "claude",
392
393
  [
@@ -401,9 +402,13 @@ function pingOne(account) {
401
402
  ],
402
403
  {
403
404
  env: { ...process.env, CLAUDE_CONFIG_DIR: account.configDir },
404
- timeout: 3e4
405
+ timeout: PING_TIMEOUT_MS,
406
+ killSignal: "SIGKILL"
405
407
  },
406
408
  (error, stdout) => {
409
+ if (resolved) return;
410
+ resolved = true;
411
+ clearTimeout(hardKillTimer);
407
412
  const claudeResponse = parseClaudeResponse(stdout) ?? void 0;
408
413
  const isError = claudeResponse?.is_error === true;
409
414
  let errorMsg;
@@ -416,7 +421,7 @@ function pingOne(account) {
416
421
  } else if (isError) {
417
422
  errorMsg = claudeResponse?.subtype;
418
423
  }
419
- resolve({
424
+ resolve2({
420
425
  handle: account.handle,
421
426
  success: !error && !isError,
422
427
  durationMs: Date.now() - start,
@@ -425,6 +430,21 @@ function pingOne(account) {
425
430
  });
426
431
  }
427
432
  );
433
+ const hardKillTimer = setTimeout(() => {
434
+ if (resolved) return;
435
+ resolved = true;
436
+ try {
437
+ child.kill("SIGKILL");
438
+ } catch {
439
+ }
440
+ resolve2({
441
+ handle: account.handle,
442
+ success: false,
443
+ durationMs: Date.now() - start,
444
+ error: "timed out"
445
+ });
446
+ }, PING_TIMEOUT_MS + KILL_GRACE_MS);
447
+ hardKillTimer.unref();
428
448
  child.stdin?.end();
429
449
  });
430
450
  }
@@ -438,11 +458,14 @@ async function pingAccounts(accounts, options = {}) {
438
458
  }
439
459
  return results;
440
460
  }
461
+ var PING_TIMEOUT_MS, KILL_GRACE_MS;
441
462
  var init_ping = __esm({
442
463
  "src/ping.ts"() {
443
464
  "use strict";
444
465
  init_parse();
445
466
  init_prompt();
467
+ PING_TIMEOUT_MS = 3e4;
468
+ KILL_GRACE_MS = 5e3;
446
469
  }
447
470
  });
448
471
 
@@ -465,7 +488,7 @@ async function runPing(accounts, options) {
465
488
  }
466
489
  }
467
490
  logger.log(`Pinging ${accounts.length} account(s)...`);
468
- const sleep = options._sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
491
+ const sleep = options._sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
469
492
  let results;
470
493
  if (options.staggerMs && options.staggerMs > 0 && accounts.length > 1) {
471
494
  results = [];
@@ -517,10 +540,10 @@ async function runPing(accounts, options) {
517
540
  ringBell();
518
541
  }
519
542
  if (failed > 0 && options.notify) {
520
- const failedHandles = results.filter((r) => !r.success).map((r) => r.handle);
543
+ const failedHandles2 = results.filter((r) => !r.success).map((r) => r.handle);
521
544
  await sendNotification(
522
545
  "cc-ping: ping failure",
523
- `${failed} account(s) failed: ${failedHandles.join(", ")}`
546
+ `${failed} account(s) failed: ${failedHandles2.join(", ")}`
524
547
  );
525
548
  }
526
549
  if (options.notify) {
@@ -533,6 +556,7 @@ async function runPing(accounts, options) {
533
556
  await sendNotification("cc-ping: new window", body, { sound: true });
534
557
  }
535
558
  }
559
+ const failedHandles = results.filter((r) => !r.success).map((r) => r.handle);
536
560
  if (options.json) {
537
561
  const jsonResults = results.map((r) => ({
538
562
  handle: r.handle,
@@ -541,11 +565,11 @@ async function runPing(accounts, options) {
541
565
  error: r.error
542
566
  }));
543
567
  stdout(JSON.stringify(jsonResults, null, 2));
544
- return failed > 0 ? 1 : 0;
568
+ return { exitCode: failed > 0 ? 1 : 0, failedHandles };
545
569
  }
546
570
  if (failed > 0) {
547
571
  logger.error(`${failed}/${results.length} failed`);
548
- return 1;
572
+ return { exitCode: 1, failedHandles };
549
573
  }
550
574
  logger.log(`
551
575
  All ${results.length} accounts pinged successfully`);
@@ -558,7 +582,7 @@ All ${results.length} accounts pinged successfully`);
558
582
  );
559
583
  }
560
584
  }
561
- return 0;
585
+ return { exitCode: 0, failedHandles: [] };
562
586
  }
563
587
  var init_run_ping = __esm({
564
588
  "src/run-ping.ts"() {
@@ -573,6 +597,101 @@ var init_run_ping = __esm({
573
597
  }
574
598
  });
575
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
+
576
695
  // src/daemon.ts
577
696
  var daemon_exports = {};
578
697
  __export(daemon_exports, {
@@ -593,23 +712,23 @@ __export(daemon_exports, {
593
712
  });
594
713
  import { execSync, spawn } from "child_process";
595
714
  import {
596
- existsSync as existsSync5,
715
+ existsSync as existsSync6,
597
716
  closeSync as fsCloseSync,
598
717
  openSync as fsOpenSync,
599
718
  mkdirSync as mkdirSync4,
600
- readFileSync as readFileSync5,
719
+ readFileSync as readFileSync6,
601
720
  unlinkSync,
602
721
  writeFileSync as writeFileSync3
603
722
  } from "fs";
604
- import { join as join6 } from "path";
723
+ import { join as join7 } from "path";
605
724
  function daemonPidPath() {
606
- return join6(resolveConfigDir(), "daemon.json");
725
+ return join7(resolveConfigDir(), "daemon.json");
607
726
  }
608
727
  function daemonLogPath() {
609
- return join6(resolveConfigDir(), "daemon.log");
728
+ return join7(resolveConfigDir(), "daemon.log");
610
729
  }
611
730
  function daemonStopPath() {
612
- return join6(resolveConfigDir(), "daemon.stop");
731
+ return join7(resolveConfigDir(), "daemon.stop");
613
732
  }
614
733
  function writeDaemonState(state) {
615
734
  const configDir = resolveConfigDir();
@@ -619,9 +738,9 @@ function writeDaemonState(state) {
619
738
  }
620
739
  function readDaemonState() {
621
740
  const pidPath = daemonPidPath();
622
- if (!existsSync5(pidPath)) return null;
741
+ if (!existsSync6(pidPath)) return null;
623
742
  try {
624
- const raw = readFileSync5(pidPath, "utf-8");
743
+ const raw = readFileSync6(pidPath, "utf-8");
625
744
  return JSON.parse(raw);
626
745
  } catch {
627
746
  return null;
@@ -629,7 +748,7 @@ function readDaemonState() {
629
748
  }
630
749
  function removeDaemonState() {
631
750
  const pidPath = daemonPidPath();
632
- if (!existsSync5(pidPath)) return false;
751
+ if (!existsSync6(pidPath)) return false;
633
752
  unlinkSync(pidPath);
634
753
  return true;
635
754
  }
@@ -703,11 +822,25 @@ async function daemonLoop(intervalMs, options, deps) {
703
822
  let wakeDelayMs;
704
823
  while (!deps.shouldStop()) {
705
824
  const allAccounts = deps.listAccounts();
706
- 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;
707
826
  const skipped = allAccounts.length - accounts.length;
708
827
  if (skipped > 0) {
709
828
  deps.log(`Skipping ${skipped} account(s) with active window`);
710
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
+ }
711
844
  if (accounts.length === 0) {
712
845
  deps.log(
713
846
  allAccounts.length === 0 ? "No accounts configured, waiting..." : "All accounts have active windows, waiting..."
@@ -716,13 +849,26 @@ async function daemonLoop(intervalMs, options, deps) {
716
849
  deps.log(
717
850
  `[${(/* @__PURE__ */ new Date()).toISOString()}] Pinging ${accounts.length} account(s)...`
718
851
  );
719
- await deps.runPing(accounts, {
852
+ const pingOpts = {
720
853
  parallel: false,
721
854
  quiet: options.quiet ?? true,
722
855
  bell: options.bell,
723
856
  notify: options.notify,
724
857
  wakeDelayMs
725
- });
858
+ };
859
+ const { failedHandles } = await deps.runPing(accounts, pingOpts);
860
+ if (failedHandles.length > 0 && !deps.shouldStop()) {
861
+ const retryAccounts = accounts.filter(
862
+ (a) => failedHandles.includes(a.handle)
863
+ );
864
+ if (retryAccounts.length > 0) {
865
+ deps.log(`Retrying ${retryAccounts.length} account(s)...`);
866
+ const retry = await deps.runPing(retryAccounts, pingOpts);
867
+ if (retry.failedHandles.length > 0) {
868
+ deps.log(`Retry failed for: ${retry.failedHandles.join(", ")}`);
869
+ }
870
+ }
871
+ }
726
872
  deps.updateState?.({ lastPingAt: (/* @__PURE__ */ new Date()).toISOString() });
727
873
  }
728
874
  if (deps.shouldStop()) break;
@@ -766,6 +912,7 @@ function startDaemon(options, deps) {
766
912
  if (options.quiet) args.push("--quiet");
767
913
  if (options.bell) args.push("--bell");
768
914
  if (options.notify) args.push("--notify");
915
+ if (options.smartSchedule === false) args.push("--smart-schedule", "off");
769
916
  const child = _spawn(process.execPath, [process.argv[1], ...args], {
770
917
  detached: true,
771
918
  stdio: ["ignore", logFd, logFd],
@@ -798,9 +945,9 @@ async function stopDaemon(deps) {
798
945
  const _removeStopFile = deps?.removeStopFile ?? /* c8 ignore next 4 -- production default */
799
946
  (() => {
800
947
  const stopPath = daemonStopPath();
801
- if (existsSync5(stopPath)) unlinkSync(stopPath);
948
+ if (existsSync6(stopPath)) unlinkSync(stopPath);
802
949
  });
803
- const _sleep = deps?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
950
+ const _sleep = deps?.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
804
951
  const _kill = deps?.kill ?? /* c8 ignore next 7 -- production default */
805
952
  ((pid2) => {
806
953
  if (process.platform === "win32") {
@@ -834,9 +981,9 @@ async function stopDaemon(deps) {
834
981
  }
835
982
  async function runDaemon(intervalMs, options, deps) {
836
983
  const stopPath = daemonStopPath();
837
- if (existsSync5(stopPath)) unlinkSync(stopPath);
984
+ if (existsSync6(stopPath)) unlinkSync(stopPath);
838
985
  const cleanup = () => {
839
- if (existsSync5(stopPath)) unlinkSync(stopPath);
986
+ if (existsSync6(stopPath)) unlinkSync(stopPath);
840
987
  removeDaemonState();
841
988
  };
842
989
  const onSigterm = () => {
@@ -866,13 +1013,36 @@ async function runDaemonWithDefaults(intervalMs, options) {
866
1013
  const { runPing: runPing2 } = await Promise.resolve().then(() => (init_run_ping(), run_ping_exports));
867
1014
  const { listAccounts: listAccounts2 } = await Promise.resolve().then(() => (init_config(), config_exports));
868
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
+ }
869
1038
  await runDaemon(intervalMs, options, {
870
1039
  runPing: runPing2,
871
1040
  listAccounts: listAccounts2,
872
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
873
- shouldStop: () => existsSync5(stopPath),
1041
+ sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
1042
+ shouldStop: () => existsSync6(stopPath),
874
1043
  log: (msg) => console.log(msg),
875
1044
  isWindowActive: (handle) => getWindowReset2(handle) !== null,
1045
+ shouldDeferPing,
876
1046
  updateState: (patch) => {
877
1047
  const current = readDaemonState();
878
1048
  if (current) writeDaemonState({ ...current, ...patch });
@@ -889,7 +1059,7 @@ var init_daemon = __esm({
889
1059
  init_paths();
890
1060
  init_state();
891
1061
  GRACEFUL_POLL_MS = 500;
892
- GRACEFUL_POLL_ATTEMPTS = 20;
1062
+ GRACEFUL_POLL_ATTEMPTS = 120;
893
1063
  POST_KILL_DELAY_MS = 1e3;
894
1064
  }
895
1065
  });
@@ -913,7 +1083,7 @@ import {
913
1083
  writeFileSync as nodeWriteFileSync
914
1084
  } from "fs";
915
1085
  import { homedir as nodeHomedir } from "os";
916
- import { dirname, join as join9 } from "path";
1086
+ import { dirname, join as join10 } from "path";
917
1087
  function resolveExecutable(deps) {
918
1088
  try {
919
1089
  const path = deps.execSync("which cc-ping", {
@@ -942,10 +1112,12 @@ function generateLaunchdPlist(options, execInfo, configDir) {
942
1112
  if (options.quiet) programArgs.push("--quiet");
943
1113
  if (options.bell) programArgs.push("--bell");
944
1114
  if (options.notify) programArgs.push("--notify");
1115
+ if (options.smartSchedule === false)
1116
+ programArgs.push("--smart-schedule", "off");
945
1117
  const allArgs = [execInfo.executable, ...programArgs];
946
1118
  const argsXml = allArgs.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
947
- const logPath = join9(
948
- configDir || join9(nodeHomedir(), ".config", "cc-ping"),
1119
+ const logPath = join10(
1120
+ configDir || join10(nodeHomedir(), ".config", "cc-ping"),
949
1121
  "daemon.log"
950
1122
  );
951
1123
  let envSection = "";
@@ -994,6 +1166,8 @@ function generateSystemdUnit(options, execInfo, configDir) {
994
1166
  if (options.quiet) programArgs.push("--quiet");
995
1167
  if (options.bell) programArgs.push("--bell");
996
1168
  if (options.notify) programArgs.push("--notify");
1169
+ if (options.smartSchedule === false)
1170
+ programArgs.push("--smart-schedule", "off");
997
1171
  const execStart = [execInfo.executable, ...programArgs].map((a) => a.includes(" ") ? `"${a}"` : a).join(" ");
998
1172
  let envLine = "";
999
1173
  if (configDir) {
@@ -1016,9 +1190,9 @@ WantedBy=default.target
1016
1190
  function servicePath(platform, home) {
1017
1191
  switch (platform) {
1018
1192
  case "darwin":
1019
- return join9(home, "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
1193
+ return join10(home, "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
1020
1194
  case "linux":
1021
- return join9(
1195
+ return join10(
1022
1196
  home,
1023
1197
  ".config",
1024
1198
  "systemd",
@@ -1164,7 +1338,7 @@ var init_service = __esm({
1164
1338
  });
1165
1339
 
1166
1340
  // src/cli.ts
1167
- import { basename } from "path";
1341
+ import { basename, resolve } from "path";
1168
1342
  import { Command } from "commander";
1169
1343
 
1170
1344
  // src/check.ts
@@ -1429,12 +1603,12 @@ init_daemon();
1429
1603
  init_config();
1430
1604
 
1431
1605
  // src/identity.ts
1432
- import { readFileSync as readFileSync6 } from "fs";
1433
- import { join as join7 } from "path";
1606
+ import { readFileSync as readFileSync7 } from "fs";
1607
+ import { join as join8 } from "path";
1434
1608
  function readAccountIdentity(configDir) {
1435
1609
  let raw;
1436
1610
  try {
1437
- raw = readFileSync6(join7(configDir, ".claude.json"), "utf-8");
1611
+ raw = readFileSync7(join8(configDir, ".claude.json"), "utf-8");
1438
1612
  } catch {
1439
1613
  return null;
1440
1614
  }
@@ -1625,21 +1799,24 @@ init_paths();
1625
1799
  init_run_ping();
1626
1800
 
1627
1801
  // src/scan.ts
1628
- import { existsSync as existsSync6, readdirSync, statSync as statSync2 } from "fs";
1802
+ import { existsSync as existsSync7, readdirSync, statSync as statSync2 } from "fs";
1629
1803
  import { homedir as homedir2 } from "os";
1630
- import { join as join8 } from "path";
1631
- var ACCOUNTS_DIR = join8(homedir2(), ".claude-accounts");
1632
- function scanAccounts() {
1633
- if (!existsSync6(ACCOUNTS_DIR)) return [];
1634
- return readdirSync(ACCOUNTS_DIR).filter((name) => {
1635
- const full = join8(ACCOUNTS_DIR, name);
1636
- 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"));
1637
1811
  }).map((name) => ({
1638
1812
  handle: name,
1639
- configDir: join8(ACCOUNTS_DIR, name)
1813
+ configDir: join9(accountsDir, name)
1640
1814
  }));
1641
1815
  }
1642
1816
 
1817
+ // src/cli.ts
1818
+ init_schedule();
1819
+
1643
1820
  // src/stagger.ts
1644
1821
  init_state();
1645
1822
  function calculateStagger(accountCount, windowMs = QUOTA_WINDOW_MS) {
@@ -1691,7 +1868,7 @@ function suggestAccount(accounts, now = /* @__PURE__ */ new Date()) {
1691
1868
  }
1692
1869
 
1693
1870
  // src/cli.ts
1694
- var program = new Command().name("cc-ping").description("Ping Claude Code sessions to trigger quota windows early").version("1.3.1").option(
1871
+ var program = new Command().name("cc-ping").description("Ping Claude Code sessions to trigger quota windows early").version("1.4.0").option(
1695
1872
  "--config <path>",
1696
1873
  "Path to config directory (default: ~/.config/cc-ping, env: CC_PING_CONFIG)"
1697
1874
  ).hook("preAction", (thisCommand) => {
@@ -1724,7 +1901,7 @@ program.command("ping").description("Ping configured accounts to start quota win
1724
1901
  process.exit(0);
1725
1902
  }
1726
1903
  const staggerMs = opts.stagger ? parseStagger(opts.stagger, targets.length) : void 0;
1727
- const exitCode = await runPing(targets, {
1904
+ const { exitCode } = await runPing(targets, {
1728
1905
  parallel: opts.parallel,
1729
1906
  quiet: opts.quiet,
1730
1907
  json: opts.json,
@@ -1757,10 +1934,11 @@ program.command("check").description(
1757
1934
  process.exit(1);
1758
1935
  }
1759
1936
  });
1760
- program.command("scan").description("Auto-discover accounts from ~/.claude-accounts/").option("--dry-run", "Show what would be added without saving", false).action((opts) => {
1761
- 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);
1762
1940
  if (found.length === 0) {
1763
- console.log("No accounts found in ~/.claude-accounts/");
1941
+ console.log(`No accounts found in ${scanDir ?? "~"}`);
1764
1942
  return;
1765
1943
  }
1766
1944
  console.log(`Found ${found.length} account(s):`);
@@ -1897,12 +2075,20 @@ var daemon = program.command("daemon").description("Run auto-ping on a schedule"
1897
2075
  daemon.command("start").description("Start the daemon process").option(
1898
2076
  "--interval <minutes>",
1899
2077
  "Ping interval in minutes (default: 300 = 5h quota window)"
1900
- ).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
+ }
1901
2086
  const result = startDaemon({
1902
2087
  interval: opts.interval,
1903
2088
  quiet: opts.quiet,
1904
2089
  bell: opts.bell,
1905
- notify: opts.notify
2090
+ notify: opts.notify,
2091
+ smartSchedule
1906
2092
  });
1907
2093
  if (!result.success) {
1908
2094
  console.error(result.error);
@@ -1991,13 +2177,21 @@ daemon.command("status").description("Show daemon status").option("--json", "Out
1991
2177
  daemon.command("install").description("Install daemon as a system service (launchd/systemd)").option(
1992
2178
  "--interval <minutes>",
1993
2179
  "Ping interval in minutes (default: 300 = 5h quota window)"
1994
- ).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
+ }
1995
2188
  const { installService: installService2 } = await Promise.resolve().then(() => (init_service(), service_exports));
1996
2189
  const result = await installService2({
1997
2190
  interval: opts.interval,
1998
2191
  quiet: opts.quiet,
1999
2192
  bell: opts.bell,
2000
- notify: opts.notify
2193
+ notify: opts.notify,
2194
+ smartSchedule
2001
2195
  });
2002
2196
  if (!result.success) {
2003
2197
  console.error(result.error);
@@ -2017,12 +2211,16 @@ daemon.command("uninstall").description("Remove daemon system service").action(a
2017
2211
  }
2018
2212
  console.log(`Service removed: ${result.servicePath}`);
2019
2213
  });
2020
- 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) => {
2021
2215
  const intervalMs = Number(opts.intervalMs);
2022
2216
  if (!intervalMs || intervalMs <= 0) {
2023
2217
  console.error("Invalid --interval-ms");
2024
2218
  process.exit(1);
2025
2219
  }
2220
+ let smartSchedule;
2221
+ if (opts.smartSchedule !== void 0) {
2222
+ smartSchedule = parseSmartSchedule(opts.smartSchedule);
2223
+ }
2026
2224
  if (!readDaemonState()) {
2027
2225
  const { resolveConfigDir: resolveConfigDir2 } = await Promise.resolve().then(() => (init_paths(), paths_exports));
2028
2226
  writeDaemonState({
@@ -2035,7 +2233,8 @@ daemon.command("_run", { hidden: true }).option("--interval-ms <ms>", "Ping inte
2035
2233
  await runDaemonWithDefaults(intervalMs, {
2036
2234
  quiet: opts.quiet,
2037
2235
  bell: opts.bell,
2038
- notify: opts.notify
2236
+ notify: opts.notify,
2237
+ smartSchedule
2039
2238
  });
2040
2239
  });
2041
2240
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/cc-ping",
3
- "version": "1.3.1",
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": {