@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.
- package/README.md +48 -5
- package/dist/cli.js +255 -55
- 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
|
|
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
|
|
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
|
|
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((
|
|
293
|
+
return new Promise((resolve2) => {
|
|
294
294
|
exec(cmd[0], cmd[1], (error) => {
|
|
295
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
|
715
|
+
existsSync as existsSync6,
|
|
621
716
|
closeSync as fsCloseSync,
|
|
622
717
|
openSync as fsOpenSync,
|
|
623
718
|
mkdirSync as mkdirSync4,
|
|
624
|
-
readFileSync as
|
|
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
|
|
725
|
+
import { join as join7 } from "path";
|
|
629
726
|
function daemonPidPath() {
|
|
630
|
-
return
|
|
727
|
+
return join7(resolveConfigDir(), "daemon.json");
|
|
631
728
|
}
|
|
632
729
|
function daemonLogPath() {
|
|
633
|
-
return
|
|
730
|
+
return join7(resolveConfigDir(), "daemon.log");
|
|
634
731
|
}
|
|
635
732
|
function daemonStopPath() {
|
|
636
|
-
return
|
|
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 (!
|
|
743
|
+
if (!existsSync6(pidPath)) return null;
|
|
647
744
|
try {
|
|
648
|
-
const raw =
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
960
|
+
if (existsSync6(stopPath)) unlinkSync(stopPath);
|
|
839
961
|
});
|
|
840
|
-
const _sleep = deps?.sleep ?? ((ms) => new Promise((
|
|
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 (
|
|
996
|
+
if (existsSync6(stopPath)) unlinkSync(stopPath);
|
|
875
997
|
const cleanup = () => {
|
|
876
|
-
if (
|
|
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((
|
|
910
|
-
shouldStop: () =>
|
|
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
|
|
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 =
|
|
985
|
-
configDir ||
|
|
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
|
|
1218
|
+
return join10(home, "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
1057
1219
|
case "linux":
|
|
1058
|
-
return
|
|
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
|
|
1470
|
-
import { join as
|
|
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 =
|
|
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
|
|
1827
|
+
import { existsSync as existsSync7, readdirSync, statSync as statSync3 } from "fs";
|
|
1666
1828
|
import { homedir as homedir2 } from "os";
|
|
1667
|
-
import { join as
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
if (!
|
|
1671
|
-
return readdirSync(
|
|
1672
|
-
const full =
|
|
1673
|
-
return
|
|
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:
|
|
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.
|
|
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
|
|
1798
|
-
const
|
|
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(
|
|
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).
|
|
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).
|
|
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();
|