@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.
- package/README.md +47 -5
- package/dist/cli.js +258 -59
- 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,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
|
|
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((
|
|
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,8 @@ 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
|
+
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:
|
|
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
|
-
|
|
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((
|
|
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
|
|
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: ${
|
|
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
|
|
715
|
+
existsSync as existsSync6,
|
|
597
716
|
closeSync as fsCloseSync,
|
|
598
717
|
openSync as fsOpenSync,
|
|
599
718
|
mkdirSync as mkdirSync4,
|
|
600
|
-
readFileSync as
|
|
719
|
+
readFileSync as readFileSync6,
|
|
601
720
|
unlinkSync,
|
|
602
721
|
writeFileSync as writeFileSync3
|
|
603
722
|
} from "fs";
|
|
604
|
-
import { join as
|
|
723
|
+
import { join as join7 } from "path";
|
|
605
724
|
function daemonPidPath() {
|
|
606
|
-
return
|
|
725
|
+
return join7(resolveConfigDir(), "daemon.json");
|
|
607
726
|
}
|
|
608
727
|
function daemonLogPath() {
|
|
609
|
-
return
|
|
728
|
+
return join7(resolveConfigDir(), "daemon.log");
|
|
610
729
|
}
|
|
611
730
|
function daemonStopPath() {
|
|
612
|
-
return
|
|
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 (!
|
|
741
|
+
if (!existsSync6(pidPath)) return null;
|
|
623
742
|
try {
|
|
624
|
-
const raw =
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
948
|
+
if (existsSync6(stopPath)) unlinkSync(stopPath);
|
|
802
949
|
});
|
|
803
|
-
const _sleep = deps?.sleep ?? ((ms) => new Promise((
|
|
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 (
|
|
984
|
+
if (existsSync6(stopPath)) unlinkSync(stopPath);
|
|
838
985
|
const cleanup = () => {
|
|
839
|
-
if (
|
|
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((
|
|
873
|
-
shouldStop: () =>
|
|
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 =
|
|
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
|
|
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 =
|
|
948
|
-
configDir ||
|
|
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
|
|
1193
|
+
return join10(home, "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
1020
1194
|
case "linux":
|
|
1021
|
-
return
|
|
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
|
|
1433
|
-
import { join as
|
|
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 =
|
|
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
|
|
1802
|
+
import { existsSync as existsSync7, readdirSync, statSync as statSync2 } from "fs";
|
|
1629
1803
|
import { homedir as homedir2 } from "os";
|
|
1630
|
-
import { join as
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
if (!
|
|
1634
|
-
return readdirSync(
|
|
1635
|
-
const full =
|
|
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:
|
|
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.
|
|
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
|
|
1761
|
-
const
|
|
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(
|
|
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).
|
|
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).
|
|
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();
|