@pimmesz/afterburner 1.0.9 → 1.0.11
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/dist/{chunk-OZAFLQDP.js → chunk-Z3H5GIPM.js} +231 -194
- package/dist/cli/index.js +127 -5
- package/dist/core/index.js +1 -1
- package/package.json +1 -1
|
@@ -417,37 +417,6 @@ function budgetFromUsageCache(cache, opts, nowMs) {
|
|
|
417
417
|
return { budget: { sessionAvailable, weeklyRemainingPct, weeklyRemainingTokensEst } };
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
-
// src/core/store/run-store.ts
|
|
421
|
-
import { appendFile, mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
|
|
422
|
-
import { dirname as dirname2 } from "path";
|
|
423
|
-
var JsonlRunStore = class {
|
|
424
|
-
constructor(filePath = defaultRunStorePath()) {
|
|
425
|
-
this.filePath = filePath;
|
|
426
|
-
}
|
|
427
|
-
filePath;
|
|
428
|
-
async append(record) {
|
|
429
|
-
await mkdir2(dirname2(this.filePath), { recursive: true });
|
|
430
|
-
await appendFile(this.filePath, `${JSON.stringify(record)}
|
|
431
|
-
`, "utf8");
|
|
432
|
-
}
|
|
433
|
-
async list() {
|
|
434
|
-
let raw;
|
|
435
|
-
try {
|
|
436
|
-
raw = await readFile3(this.filePath, "utf8");
|
|
437
|
-
} catch (error) {
|
|
438
|
-
if (error.code === "ENOENT") return [];
|
|
439
|
-
throw error;
|
|
440
|
-
}
|
|
441
|
-
return raw.split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
|
|
442
|
-
}
|
|
443
|
-
async hasOpenOrMergedPr(fingerprint) {
|
|
444
|
-
const records = await this.list();
|
|
445
|
-
return records.some(
|
|
446
|
-
(r) => r.fingerprint === fingerprint && (r.outcome === "pr-opened" || !!r.prUrl)
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
};
|
|
450
|
-
|
|
451
420
|
// src/core/budget/claude-usage.ts
|
|
452
421
|
var ClaudeUsageBudgetProvider = class {
|
|
453
422
|
constructor(opts) {
|
|
@@ -519,16 +488,6 @@ function createBudgetProvider(config, opts = {}) {
|
|
|
519
488
|
};
|
|
520
489
|
}
|
|
521
490
|
|
|
522
|
-
// src/core/notify/console.ts
|
|
523
|
-
var ConsoleNotifier = class {
|
|
524
|
-
async notify(record) {
|
|
525
|
-
const pr = record.prUrl ? ` pr=${record.prUrl}` : "";
|
|
526
|
-
console.log(
|
|
527
|
-
`[afterburner] ${record.outcome} ${record.category} "${record.title}" repo=${record.repoUrl} branch=${record.branch} est=${record.estCostSonnetTokens.toLocaleString("en-US")} sonnet-eq tokens${pr}`
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
};
|
|
531
|
-
|
|
532
491
|
// src/core/scheduler/gate.ts
|
|
533
492
|
function shouldIgnite(budget, estCostSonnetTokens, config) {
|
|
534
493
|
if (!Number.isFinite(budget.weeklyRemainingPct) || !Number.isFinite(budget.weeklyRemainingTokensEst) || !Number.isFinite(estCostSonnetTokens) || !Number.isFinite(config.safetyMarginTokens)) {
|
|
@@ -553,6 +512,228 @@ function shouldIgnite(budget, estCostSonnetTokens, config) {
|
|
|
553
512
|
return { go: true, reason: "session available and estimated cost fits within weekly headroom" };
|
|
554
513
|
}
|
|
555
514
|
|
|
515
|
+
// src/core/scheduler/native.ts
|
|
516
|
+
import { homedir as homedir2 } from "os";
|
|
517
|
+
import { join as join4 } from "path";
|
|
518
|
+
function parseSimpleCron(cron) {
|
|
519
|
+
const parts = cron.trim().split(/\s+/);
|
|
520
|
+
if (parts.length !== 5) throw unsupportedCron(cron);
|
|
521
|
+
const [minutePart, hourPart, dom, month, dow] = parts;
|
|
522
|
+
if (dom !== "*" || month !== "*" || dow !== "*") throw unsupportedCron(cron);
|
|
523
|
+
const minute = Number(minutePart);
|
|
524
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 59) throw unsupportedCron(cron);
|
|
525
|
+
const allHours = Array.from({ length: 24 }, (_, h) => h);
|
|
526
|
+
let hours;
|
|
527
|
+
if (hourPart === "*") {
|
|
528
|
+
hours = allHours;
|
|
529
|
+
} else if (/^\*\/\d+$/.test(hourPart)) {
|
|
530
|
+
const step = Number(hourPart.slice(2));
|
|
531
|
+
if (step < 1 || step > 23) throw unsupportedCron(cron);
|
|
532
|
+
hours = allHours.filter((h) => h % step === 0);
|
|
533
|
+
} else if (/^\d+(,\d+)*$/.test(hourPart)) {
|
|
534
|
+
hours = hourPart.split(",").map(Number);
|
|
535
|
+
if (hours.some((h) => h < 0 || h > 23)) throw unsupportedCron(cron);
|
|
536
|
+
} else {
|
|
537
|
+
throw unsupportedCron(cron);
|
|
538
|
+
}
|
|
539
|
+
return { minute, hours };
|
|
540
|
+
}
|
|
541
|
+
function unsupportedCron(cron) {
|
|
542
|
+
return new Error(
|
|
543
|
+
`Cron expression "${cron}" is too complex for native scheduler installation. Supported shapes: "M * * * *", "M H * * *", "M */N * * *", "M H1,H2 * * *". Use \`afterburner watch\` for full cron support, or install a native entry manually.`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
function nextSimpleCronFire(schedule2, now, timezone) {
|
|
547
|
+
const wallClock = new Intl.DateTimeFormat("en-GB", {
|
|
548
|
+
timeZone: timezone,
|
|
549
|
+
hour: "2-digit",
|
|
550
|
+
minute: "2-digit",
|
|
551
|
+
hourCycle: "h23"
|
|
552
|
+
});
|
|
553
|
+
const start = Math.floor(now.getTime() / 6e4 + 1) * 6e4;
|
|
554
|
+
for (let i = 0; i < 49 * 60; i++) {
|
|
555
|
+
const candidate = new Date(start + i * 6e4);
|
|
556
|
+
const parts = wallClock.formatToParts(candidate);
|
|
557
|
+
const hour = Number(parts.find((p) => p.type === "hour")?.value);
|
|
558
|
+
const minute = Number(parts.find((p) => p.type === "minute")?.value);
|
|
559
|
+
if (minute === schedule2.minute && schedule2.hours.includes(hour)) return candidate;
|
|
560
|
+
}
|
|
561
|
+
throw new Error(`No fire time within 49h for the schedule; is timezone "${timezone}" valid?`);
|
|
562
|
+
}
|
|
563
|
+
var launchdPlistPath = () => join4(homedir2(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
564
|
+
var systemdUnitPaths = () => {
|
|
565
|
+
const unitDir = join4(homedir2(), ".config", "systemd", "user");
|
|
566
|
+
return {
|
|
567
|
+
service: join4(unitDir, "afterburner.service"),
|
|
568
|
+
timer: join4(unitDir, "afterburner.timer")
|
|
569
|
+
};
|
|
570
|
+
};
|
|
571
|
+
function scheduleArtifactPaths(platform) {
|
|
572
|
+
switch (platform) {
|
|
573
|
+
case "darwin":
|
|
574
|
+
return [launchdPlistPath()];
|
|
575
|
+
case "linux": {
|
|
576
|
+
const units = systemdUnitPaths();
|
|
577
|
+
return [units.service, units.timer];
|
|
578
|
+
}
|
|
579
|
+
case "win32":
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function generateScheduleArtifacts(platform, opts) {
|
|
584
|
+
const schedule2 = parseSimpleCron(opts.cron);
|
|
585
|
+
const command = [
|
|
586
|
+
opts.nodePath,
|
|
587
|
+
opts.cliPath,
|
|
588
|
+
"run-once",
|
|
589
|
+
...opts.configPath ? ["--config", opts.configPath] : []
|
|
590
|
+
];
|
|
591
|
+
switch (platform) {
|
|
592
|
+
case "darwin":
|
|
593
|
+
return launchdArtifacts(schedule2, opts, command);
|
|
594
|
+
case "linux":
|
|
595
|
+
return systemdArtifacts(schedule2, opts, command);
|
|
596
|
+
case "win32":
|
|
597
|
+
return schtasksArtifacts(schedule2, opts, command);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
var LAUNCHD_LABEL = "io.afterburner.run-once";
|
|
601
|
+
function launchdArtifacts(schedule2, opts, command) {
|
|
602
|
+
const plistPath = launchdPlistPath();
|
|
603
|
+
const intervals = schedule2.hours.map(
|
|
604
|
+
(hour) => ` <dict>
|
|
605
|
+
<key>Hour</key><integer>${hour}</integer>
|
|
606
|
+
<key>Minute</key><integer>${schedule2.minute}</integer>
|
|
607
|
+
</dict>`
|
|
608
|
+
).join("\n");
|
|
609
|
+
const programArgs = command.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
|
|
610
|
+
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
611
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
612
|
+
<plist version="1.0">
|
|
613
|
+
<dict>
|
|
614
|
+
<key>Label</key>
|
|
615
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
616
|
+
<key>ProgramArguments</key>
|
|
617
|
+
<array>
|
|
618
|
+
${programArgs}
|
|
619
|
+
</array>
|
|
620
|
+
<key>StartCalendarInterval</key>
|
|
621
|
+
<array>
|
|
622
|
+
${intervals}
|
|
623
|
+
</array>
|
|
624
|
+
<key>StandardOutPath</key>
|
|
625
|
+
<string>/tmp/afterburner.out.log</string>
|
|
626
|
+
<key>StandardErrorPath</key>
|
|
627
|
+
<string>/tmp/afterburner.err.log</string>
|
|
628
|
+
</dict>
|
|
629
|
+
</plist>
|
|
630
|
+
`;
|
|
631
|
+
return {
|
|
632
|
+
kind: "launchd",
|
|
633
|
+
files: [{ path: plistPath, content }],
|
|
634
|
+
activationHint: `launchctl load -w ${plistPath}
|
|
635
|
+
(note: launchd schedules in LOCAL time; your configured timezone "${opts.timezone}" is not applied)`,
|
|
636
|
+
removalHint: `launchctl unload -w ${plistPath} && rm ${plistPath}`
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function systemdArtifacts(schedule2, opts, command) {
|
|
640
|
+
const { service: servicePath, timer: timerPath } = systemdUnitPaths();
|
|
641
|
+
const minute = String(schedule2.minute).padStart(2, "0");
|
|
642
|
+
const onCalendarLines = schedule2.hours.map(
|
|
643
|
+
(hour) => `OnCalendar=*-*-* ${String(hour).padStart(2, "0")}:${minute}:00 ${opts.timezone}`
|
|
644
|
+
).join("\n");
|
|
645
|
+
const execStart = command.map((arg) => systemdQuote(arg)).join(" ");
|
|
646
|
+
const service = `[Unit]
|
|
647
|
+
Description=Afterburner single run (budget-gated, PR-only)
|
|
648
|
+
|
|
649
|
+
[Service]
|
|
650
|
+
Type=oneshot
|
|
651
|
+
ExecStart=${execStart}
|
|
652
|
+
`;
|
|
653
|
+
const timer = `[Unit]
|
|
654
|
+
Description=Afterburner schedule
|
|
655
|
+
|
|
656
|
+
[Timer]
|
|
657
|
+
${onCalendarLines}
|
|
658
|
+
Persistent=false
|
|
659
|
+
|
|
660
|
+
[Install]
|
|
661
|
+
WantedBy=timers.target
|
|
662
|
+
`;
|
|
663
|
+
return {
|
|
664
|
+
kind: "systemd-user",
|
|
665
|
+
files: [
|
|
666
|
+
{ path: servicePath, content: service },
|
|
667
|
+
{ path: timerPath, content: timer }
|
|
668
|
+
],
|
|
669
|
+
activationHint: `systemctl --user daemon-reload && systemctl --user enable --now afterburner.timer`,
|
|
670
|
+
removalHint: `systemctl --user disable --now afterburner.timer && rm ${servicePath} ${timerPath} && systemctl --user daemon-reload`
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function schtasksArtifacts(schedule2, opts, command) {
|
|
674
|
+
const minute = String(schedule2.minute).padStart(2, "0");
|
|
675
|
+
const taskName = (hour) => `Afterburner-${String(hour).padStart(2, "0")}${minute}`;
|
|
676
|
+
const tr = command.map((arg) => `\\"${arg}\\"`).join(" ");
|
|
677
|
+
const createCommands = schedule2.hours.map(
|
|
678
|
+
(hour) => `schtasks /Create /TN "${taskName(hour)}" /TR "${tr}" /SC DAILY /ST ${String(hour).padStart(2, "0")}:${minute}`
|
|
679
|
+
).join("\n");
|
|
680
|
+
const deleteCommands = schedule2.hours.map((hour) => `schtasks /Delete /TN "${taskName(hour)}" /F`).join("\n");
|
|
681
|
+
return {
|
|
682
|
+
kind: "schtasks",
|
|
683
|
+
files: [],
|
|
684
|
+
activationHint: `${createCommands}
|
|
685
|
+
(run from cmd.exe; note: schtasks schedules in LOCAL time; your configured timezone "${opts.timezone}" is not applied)`,
|
|
686
|
+
removalHint: deleteCommands
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function escapeXml(value) {
|
|
690
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
691
|
+
}
|
|
692
|
+
function systemdQuote(value) {
|
|
693
|
+
return /[\s"']/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// src/core/store/run-store.ts
|
|
697
|
+
import { appendFile, mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
|
|
698
|
+
import { dirname as dirname2 } from "path";
|
|
699
|
+
var JsonlRunStore = class {
|
|
700
|
+
constructor(filePath = defaultRunStorePath()) {
|
|
701
|
+
this.filePath = filePath;
|
|
702
|
+
}
|
|
703
|
+
filePath;
|
|
704
|
+
async append(record) {
|
|
705
|
+
await mkdir2(dirname2(this.filePath), { recursive: true });
|
|
706
|
+
await appendFile(this.filePath, `${JSON.stringify(record)}
|
|
707
|
+
`, "utf8");
|
|
708
|
+
}
|
|
709
|
+
async list() {
|
|
710
|
+
let raw;
|
|
711
|
+
try {
|
|
712
|
+
raw = await readFile3(this.filePath, "utf8");
|
|
713
|
+
} catch (error) {
|
|
714
|
+
if (error.code === "ENOENT") return [];
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
return raw.split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
|
|
718
|
+
}
|
|
719
|
+
async hasOpenOrMergedPr(fingerprint) {
|
|
720
|
+
const records = await this.list();
|
|
721
|
+
return records.some(
|
|
722
|
+
(r) => r.fingerprint === fingerprint && (r.outcome === "pr-opened" || !!r.prUrl)
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// src/core/notify/console.ts
|
|
728
|
+
var ConsoleNotifier = class {
|
|
729
|
+
async notify(record) {
|
|
730
|
+
const pr = record.prUrl ? ` pr=${record.prUrl}` : "";
|
|
731
|
+
console.log(
|
|
732
|
+
`[afterburner] ${record.outcome} ${record.category} "${record.title}" repo=${record.repoUrl} branch=${record.branch} est=${record.estCostSonnetTokens.toLocaleString("en-US")} sonnet-eq tokens${pr}`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
|
|
556
737
|
// src/core/orchestrator.ts
|
|
557
738
|
async function runOnce(deps) {
|
|
558
739
|
const { config } = deps;
|
|
@@ -736,7 +917,7 @@ function liveDowngradeReason(config, liveFlag, configPath) {
|
|
|
736
917
|
// src/core/tasks/selector.ts
|
|
737
918
|
import { readdir as readdir2, readFile as readFile4, stat as stat2 } from "fs/promises";
|
|
738
919
|
import { existsSync as existsSync2 } from "fs";
|
|
739
|
-
import { basename, extname, join as
|
|
920
|
+
import { basename, extname, join as join5, relative } from "path";
|
|
740
921
|
var BASE_TASK_TOKENS = {
|
|
741
922
|
security: 12e4,
|
|
742
923
|
tests: 9e4,
|
|
@@ -855,7 +1036,7 @@ async function collectFiles(root) {
|
|
|
855
1036
|
const entries = await readdir2(dir, { withFileTypes: true });
|
|
856
1037
|
for (const entry of entries) {
|
|
857
1038
|
if (results.length >= MAX_FILES) return;
|
|
858
|
-
const fullPath =
|
|
1039
|
+
const fullPath = join5(dir, entry.name);
|
|
859
1040
|
if (entry.isDirectory()) {
|
|
860
1041
|
if (!SKIP_DIRS.has(entry.name)) await walk(fullPath);
|
|
861
1042
|
continue;
|
|
@@ -876,152 +1057,6 @@ async function collectFiles(root) {
|
|
|
876
1057
|
return results;
|
|
877
1058
|
}
|
|
878
1059
|
|
|
879
|
-
// src/core/scheduler/native.ts
|
|
880
|
-
import { homedir as homedir2 } from "os";
|
|
881
|
-
import { join as join5 } from "path";
|
|
882
|
-
function parseSimpleCron(cron) {
|
|
883
|
-
const parts = cron.trim().split(/\s+/);
|
|
884
|
-
if (parts.length !== 5) throw unsupportedCron(cron);
|
|
885
|
-
const [minutePart, hourPart, dom, month, dow] = parts;
|
|
886
|
-
if (dom !== "*" || month !== "*" || dow !== "*") throw unsupportedCron(cron);
|
|
887
|
-
const minute = Number(minutePart);
|
|
888
|
-
if (!Number.isInteger(minute) || minute < 0 || minute > 59) throw unsupportedCron(cron);
|
|
889
|
-
const allHours = Array.from({ length: 24 }, (_, h) => h);
|
|
890
|
-
let hours;
|
|
891
|
-
if (hourPart === "*") {
|
|
892
|
-
hours = allHours;
|
|
893
|
-
} else if (/^\*\/\d+$/.test(hourPart)) {
|
|
894
|
-
const step = Number(hourPart.slice(2));
|
|
895
|
-
if (step < 1 || step > 23) throw unsupportedCron(cron);
|
|
896
|
-
hours = allHours.filter((h) => h % step === 0);
|
|
897
|
-
} else if (/^\d+(,\d+)*$/.test(hourPart)) {
|
|
898
|
-
hours = hourPart.split(",").map(Number);
|
|
899
|
-
if (hours.some((h) => h < 0 || h > 23)) throw unsupportedCron(cron);
|
|
900
|
-
} else {
|
|
901
|
-
throw unsupportedCron(cron);
|
|
902
|
-
}
|
|
903
|
-
return { minute, hours };
|
|
904
|
-
}
|
|
905
|
-
function unsupportedCron(cron) {
|
|
906
|
-
return new Error(
|
|
907
|
-
`Cron expression "${cron}" is too complex for native scheduler installation. Supported shapes: "M * * * *", "M H * * *", "M */N * * *", "M H1,H2 * * *". Use \`afterburner watch\` for full cron support, or install a native entry manually.`
|
|
908
|
-
);
|
|
909
|
-
}
|
|
910
|
-
function generateScheduleArtifacts(platform, opts) {
|
|
911
|
-
const schedule2 = parseSimpleCron(opts.cron);
|
|
912
|
-
const command = [
|
|
913
|
-
opts.nodePath,
|
|
914
|
-
opts.cliPath,
|
|
915
|
-
"run-once",
|
|
916
|
-
...opts.configPath ? ["--config", opts.configPath] : []
|
|
917
|
-
];
|
|
918
|
-
switch (platform) {
|
|
919
|
-
case "darwin":
|
|
920
|
-
return launchdArtifacts(schedule2, opts, command);
|
|
921
|
-
case "linux":
|
|
922
|
-
return systemdArtifacts(schedule2, opts, command);
|
|
923
|
-
case "win32":
|
|
924
|
-
return schtasksArtifacts(schedule2, opts, command);
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
var LAUNCHD_LABEL = "io.afterburner.run-once";
|
|
928
|
-
function launchdArtifacts(schedule2, opts, command) {
|
|
929
|
-
const plistPath = join5(homedir2(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
930
|
-
const intervals = schedule2.hours.map(
|
|
931
|
-
(hour) => ` <dict>
|
|
932
|
-
<key>Hour</key><integer>${hour}</integer>
|
|
933
|
-
<key>Minute</key><integer>${schedule2.minute}</integer>
|
|
934
|
-
</dict>`
|
|
935
|
-
).join("\n");
|
|
936
|
-
const programArgs = command.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
|
|
937
|
-
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
938
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
939
|
-
<plist version="1.0">
|
|
940
|
-
<dict>
|
|
941
|
-
<key>Label</key>
|
|
942
|
-
<string>${LAUNCHD_LABEL}</string>
|
|
943
|
-
<key>ProgramArguments</key>
|
|
944
|
-
<array>
|
|
945
|
-
${programArgs}
|
|
946
|
-
</array>
|
|
947
|
-
<key>StartCalendarInterval</key>
|
|
948
|
-
<array>
|
|
949
|
-
${intervals}
|
|
950
|
-
</array>
|
|
951
|
-
<key>StandardOutPath</key>
|
|
952
|
-
<string>/tmp/afterburner.out.log</string>
|
|
953
|
-
<key>StandardErrorPath</key>
|
|
954
|
-
<string>/tmp/afterburner.err.log</string>
|
|
955
|
-
</dict>
|
|
956
|
-
</plist>
|
|
957
|
-
`;
|
|
958
|
-
return {
|
|
959
|
-
kind: "launchd",
|
|
960
|
-
files: [{ path: plistPath, content }],
|
|
961
|
-
activationHint: `launchctl load -w ${plistPath}
|
|
962
|
-
(note: launchd schedules in LOCAL time; your configured timezone "${opts.timezone}" is not applied)`,
|
|
963
|
-
removalHint: `launchctl unload -w ${plistPath} && rm ${plistPath}`
|
|
964
|
-
};
|
|
965
|
-
}
|
|
966
|
-
function systemdArtifacts(schedule2, opts, command) {
|
|
967
|
-
const unitDir = join5(homedir2(), ".config", "systemd", "user");
|
|
968
|
-
const servicePath = join5(unitDir, "afterburner.service");
|
|
969
|
-
const timerPath = join5(unitDir, "afterburner.timer");
|
|
970
|
-
const minute = String(schedule2.minute).padStart(2, "0");
|
|
971
|
-
const onCalendarLines = schedule2.hours.map(
|
|
972
|
-
(hour) => `OnCalendar=*-*-* ${String(hour).padStart(2, "0")}:${minute}:00 ${opts.timezone}`
|
|
973
|
-
).join("\n");
|
|
974
|
-
const execStart = command.map((arg) => systemdQuote(arg)).join(" ");
|
|
975
|
-
const service = `[Unit]
|
|
976
|
-
Description=Afterburner single run (budget-gated, PR-only)
|
|
977
|
-
|
|
978
|
-
[Service]
|
|
979
|
-
Type=oneshot
|
|
980
|
-
ExecStart=${execStart}
|
|
981
|
-
`;
|
|
982
|
-
const timer = `[Unit]
|
|
983
|
-
Description=Afterburner schedule
|
|
984
|
-
|
|
985
|
-
[Timer]
|
|
986
|
-
${onCalendarLines}
|
|
987
|
-
Persistent=false
|
|
988
|
-
|
|
989
|
-
[Install]
|
|
990
|
-
WantedBy=timers.target
|
|
991
|
-
`;
|
|
992
|
-
return {
|
|
993
|
-
kind: "systemd-user",
|
|
994
|
-
files: [
|
|
995
|
-
{ path: servicePath, content: service },
|
|
996
|
-
{ path: timerPath, content: timer }
|
|
997
|
-
],
|
|
998
|
-
activationHint: `systemctl --user daemon-reload && systemctl --user enable --now afterburner.timer`,
|
|
999
|
-
removalHint: `systemctl --user disable --now afterburner.timer && rm ${servicePath} ${timerPath} && systemctl --user daemon-reload`
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
|
-
function schtasksArtifacts(schedule2, opts, command) {
|
|
1003
|
-
const minute = String(schedule2.minute).padStart(2, "0");
|
|
1004
|
-
const taskName = (hour) => `Afterburner-${String(hour).padStart(2, "0")}${minute}`;
|
|
1005
|
-
const tr = command.map((arg) => `\\"${arg}\\"`).join(" ");
|
|
1006
|
-
const createCommands = schedule2.hours.map(
|
|
1007
|
-
(hour) => `schtasks /Create /TN "${taskName(hour)}" /TR "${tr}" /SC DAILY /ST ${String(hour).padStart(2, "0")}:${minute}`
|
|
1008
|
-
).join("\n");
|
|
1009
|
-
const deleteCommands = schedule2.hours.map((hour) => `schtasks /Delete /TN "${taskName(hour)}" /F`).join("\n");
|
|
1010
|
-
return {
|
|
1011
|
-
kind: "schtasks",
|
|
1012
|
-
files: [],
|
|
1013
|
-
activationHint: `${createCommands}
|
|
1014
|
-
(run from cmd.exe; note: schtasks schedules in LOCAL time; your configured timezone "${opts.timezone}" is not applied)`,
|
|
1015
|
-
removalHint: deleteCommands
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
function escapeXml(value) {
|
|
1019
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
1020
|
-
}
|
|
1021
|
-
function systemdQuote(value) {
|
|
1022
|
-
return /[\s"']/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
1060
|
// src/core/scheduler/watch.ts
|
|
1026
1061
|
import { schedule, validate } from "node-cron";
|
|
1027
1062
|
function startWatch(opts) {
|
|
@@ -1071,13 +1106,17 @@ export {
|
|
|
1071
1106
|
readUsageCache,
|
|
1072
1107
|
writeUsageCache,
|
|
1073
1108
|
budgetFromUsageCache,
|
|
1074
|
-
looksRemoteRepoUrl,
|
|
1075
|
-
JsonlRunStore,
|
|
1076
1109
|
ClaudeUsageBudgetProvider,
|
|
1077
1110
|
ManualBudgetProvider,
|
|
1078
1111
|
createBudgetProvider,
|
|
1079
|
-
ConsoleNotifier,
|
|
1080
1112
|
shouldIgnite,
|
|
1113
|
+
parseSimpleCron,
|
|
1114
|
+
nextSimpleCronFire,
|
|
1115
|
+
scheduleArtifactPaths,
|
|
1116
|
+
generateScheduleArtifacts,
|
|
1117
|
+
looksRemoteRepoUrl,
|
|
1118
|
+
JsonlRunStore,
|
|
1119
|
+
ConsoleNotifier,
|
|
1081
1120
|
runOnce,
|
|
1082
1121
|
ApiKeyRunner,
|
|
1083
1122
|
taskFingerprint,
|
|
@@ -1092,7 +1131,5 @@ export {
|
|
|
1092
1131
|
BASE_TASK_TOKENS,
|
|
1093
1132
|
createSelector,
|
|
1094
1133
|
DeterministicTaskSelector,
|
|
1095
|
-
parseSimpleCron,
|
|
1096
|
-
generateScheduleArtifacts,
|
|
1097
1134
|
startWatch
|
|
1098
1135
|
};
|
package/dist/cli/index.js
CHANGED
|
@@ -16,11 +16,15 @@ import {
|
|
|
16
16
|
liveDowngradeReason,
|
|
17
17
|
loadConfig,
|
|
18
18
|
looksRemoteRepoUrl,
|
|
19
|
+
nextSimpleCronFire,
|
|
20
|
+
parseSimpleCron,
|
|
19
21
|
readUsageCache,
|
|
20
22
|
runOnce,
|
|
23
|
+
scheduleArtifactPaths,
|
|
24
|
+
shouldIgnite,
|
|
21
25
|
startWatch,
|
|
22
26
|
writeUsageCache
|
|
23
|
-
} from "../chunk-
|
|
27
|
+
} from "../chunk-Z3H5GIPM.js";
|
|
24
28
|
import {
|
|
25
29
|
MCP_STUB_MESSAGE
|
|
26
30
|
} from "../chunk-2NSOEZWY.js";
|
|
@@ -31,7 +35,7 @@ import { Command } from "commander";
|
|
|
31
35
|
// package.json
|
|
32
36
|
var package_default = {
|
|
33
37
|
name: "@pimmesz/afterburner",
|
|
34
|
-
version: "1.0.
|
|
38
|
+
version: "1.0.11",
|
|
35
39
|
description: "Convert idle Claude subscription quota into shippable engineering work: budget-aware trigger, bounded task selection, PR-only output.",
|
|
36
40
|
license: "Apache-2.0",
|
|
37
41
|
publishConfig: {
|
|
@@ -337,12 +341,119 @@ async function runDoctor(opts) {
|
|
|
337
341
|
${green(`${deco(emoji.rocket)}All checks passed.`)}` : `
|
|
338
342
|
${red(`${failures} check(s) failed.`)}`
|
|
339
343
|
);
|
|
344
|
+
let summary;
|
|
345
|
+
if (failures === 0 && config && config.repos.length > 0) {
|
|
346
|
+
try {
|
|
347
|
+
summary = await buildRunSummary(config);
|
|
348
|
+
} catch {
|
|
349
|
+
}
|
|
350
|
+
}
|
|
340
351
|
console.log(
|
|
341
352
|
`
|
|
342
|
-
${renderDoctorNextSteps({
|
|
353
|
+
${renderDoctorNextSteps({
|
|
354
|
+
config,
|
|
355
|
+
configPath,
|
|
356
|
+
failed: results.filter((r) => !r.ok),
|
|
357
|
+
...summary ? { summary } : {}
|
|
358
|
+
})}`
|
|
343
359
|
);
|
|
344
360
|
process.exitCode = failures === 0 ? 0 : 1;
|
|
345
361
|
}
|
|
362
|
+
async function buildRunSummary(config) {
|
|
363
|
+
let budget = null;
|
|
364
|
+
try {
|
|
365
|
+
budget = await createBudgetProvider(config).provider.getBudget();
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
const platform = process.platform;
|
|
369
|
+
const probeable = platform === "darwin" || platform === "linux";
|
|
370
|
+
const paths = probeable ? scheduleArtifactPaths(platform) : [];
|
|
371
|
+
const scheduleInstalled = probeable ? paths.every((p) => existsSync2(p)) : "unknown";
|
|
372
|
+
return renderRunSummary({
|
|
373
|
+
config,
|
|
374
|
+
budget,
|
|
375
|
+
scheduleInstalled,
|
|
376
|
+
scheduleKind: platform === "linux" ? "systemd-user" : "launchd",
|
|
377
|
+
systemTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
378
|
+
now: /* @__PURE__ */ new Date()
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
function renderRunSummary(opts) {
|
|
382
|
+
const { config, budget } = opts;
|
|
383
|
+
const repoNames = config.repos.map((r) => r.url);
|
|
384
|
+
const repoLabel = repoNames.length === 1 ? repoNames[0] : `${repoNames.length} repos`;
|
|
385
|
+
const categories = [
|
|
386
|
+
...new Set(
|
|
387
|
+
config.repos.flatMap(
|
|
388
|
+
(r) => r.enabledTaskCategories.filter((c) => config.taskCategories[c].enabled)
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
];
|
|
392
|
+
const what = categories.length === 0 ? `Would review ${repoLabel}, but every repo task category is switched off in taskCategories` : `Reviews ${repoLabel} for ${categories.join("/")} tasks`;
|
|
393
|
+
let why;
|
|
394
|
+
if (!budget) {
|
|
395
|
+
why = `${config.budget.provider} \u2014 no numbers available right now, so a run would refuse to start.`;
|
|
396
|
+
} else {
|
|
397
|
+
const gateConfig = {
|
|
398
|
+
minWeeklyHeadroomPct: config.budget.minWeeklyHeadroomPct,
|
|
399
|
+
safetyMarginTokens: config.budget.safetyMarginTokens,
|
|
400
|
+
requireSessionAvailable: config.budget.requireSessionAvailable
|
|
401
|
+
};
|
|
402
|
+
const fullSize = shouldIgnite(budget, config.agent.maxTaskTokens, gateConfig);
|
|
403
|
+
const anySize = fullSize.go ? fullSize : shouldIgnite(budget, 0, gateConfig);
|
|
404
|
+
const verdict = fullSize.go ? "the budget gate would let a run through right now" : anySize.go ? `a task at the ${formatTokens(config.agent.maxTaskTokens)}-token cap would not fit right now, though cheaper tasks still could` : `the budget gate would block any run right now (${fullSize.reason})`;
|
|
405
|
+
const usage = `${Math.round(budget.weeklyRemainingPct)}% of the weekly quota left (~${formatTokens(Math.round(budget.weeklyRemainingTokensEst))} tokens), 5-hour session ${budget.sessionAvailable ? "available" : "exhausted"}`;
|
|
406
|
+
why = `${config.budget.provider} \u2014 ${usage} \u2192 ${verdict}.`;
|
|
407
|
+
}
|
|
408
|
+
return ` What: ${what} \u2014 at most one PR per run, up to ${formatTokens(config.agent.maxTaskTokens)} tokens.
|
|
409
|
+
When: ${renderWhen(opts)}
|
|
410
|
+
Budget: ${why}`;
|
|
411
|
+
}
|
|
412
|
+
function renderWhen(opts) {
|
|
413
|
+
const { config } = opts;
|
|
414
|
+
const cron = config.schedule.cron;
|
|
415
|
+
const cadence = describeCadence(cron);
|
|
416
|
+
if (opts.scheduleInstalled === false) {
|
|
417
|
+
return `no afterburner schedule is installed \u2014 it only runs when you start it; \`afterburner schedule install\` would run it ${cadence}.`;
|
|
418
|
+
}
|
|
419
|
+
if (opts.scheduleInstalled === "unknown") {
|
|
420
|
+
return `${cadence}, once the entries from \`afterburner schedule install\` are active (not auto-detectable on this platform).`;
|
|
421
|
+
}
|
|
422
|
+
const zone = opts.scheduleKind === "systemd-user" ? config.schedule.timezone : opts.systemTimezone;
|
|
423
|
+
const kindLabel = opts.scheduleKind === "systemd-user" ? "systemd" : "launchd";
|
|
424
|
+
const zoneNote = opts.scheduleKind === "launchd" && zone !== config.schedule.timezone ? ` \u2014 launchd fires in local ${zone} time, not the configured ${config.schedule.timezone}` : "";
|
|
425
|
+
try {
|
|
426
|
+
const next = nextSimpleCronFire(parseSimpleCron(cron), opts.now, zone);
|
|
427
|
+
const formatted = new Intl.DateTimeFormat("en-GB", {
|
|
428
|
+
timeZone: zone,
|
|
429
|
+
weekday: "short",
|
|
430
|
+
day: "numeric",
|
|
431
|
+
month: "short",
|
|
432
|
+
hour: "2-digit",
|
|
433
|
+
minute: "2-digit",
|
|
434
|
+
hourCycle: "h23"
|
|
435
|
+
}).format(next);
|
|
436
|
+
return `next run ${formatted} (${zone}), then ${cadence} \u2014 via the installed ${kindLabel} entry, if activated and in sync with this config${zoneNote}.`;
|
|
437
|
+
} catch {
|
|
438
|
+
return `an installed ${kindLabel} entry runs it on cron '${cron}' (${zone}), if activated.`;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function describeCadence(cron) {
|
|
442
|
+
let minute;
|
|
443
|
+
let hours;
|
|
444
|
+
try {
|
|
445
|
+
({ minute, hours } = parseSimpleCron(cron));
|
|
446
|
+
} catch {
|
|
447
|
+
return `on cron '${cron}'`;
|
|
448
|
+
}
|
|
449
|
+
if (hours.length === 24) return `every hour at minute ${minute}`;
|
|
450
|
+
const step2 = (hours[1] ?? 0) - (hours[0] ?? 0);
|
|
451
|
+
const uniform = hours.length > 1 && hours[0] === 0 && 24 % step2 === 0 && hours.length === 24 / step2 && hours.every((h, i) => h === i * step2);
|
|
452
|
+
if (uniform) return `every ${step2} hours at minute ${minute}`;
|
|
453
|
+
const mm = String(minute).padStart(2, "0");
|
|
454
|
+
const times = hours.map((h) => `${String(h).padStart(2, "0")}:${mm}`);
|
|
455
|
+
return `daily at ${times.join(", ")}`;
|
|
456
|
+
}
|
|
346
457
|
function renderDoctorNextSteps(opts) {
|
|
347
458
|
const next = section(emoji.rocket, "Next");
|
|
348
459
|
if (!opts.config || !opts.configPath) {
|
|
@@ -372,6 +483,17 @@ ${nextCmd(cmd("doctor"))}`;
|
|
|
372
483
|
const engineLine = config.agent.backend === "dry-run" ? "dry-run \u2014 simulation only; set agent.backend: 'claude-code' in the config to do real work" : config.agent.backend === "claude-code" ? "claude-code \u2014 spends subscription quota you already pay for; PRs only with --live" : "api-key \u2014 bills your Anthropic API account per token (real money); PRs only with --live";
|
|
373
484
|
const budgetLine = config.budget.provider === "manual" ? "manual \u2014 trusts budget.manual (automatic option: `afterburner statusline install`, then budget.provider: 'claude-usage')" : config.budget.provider === "claude-usage" ? "claude-usage \u2014 reads your real usage via the status line hook" : "claude-code-transcripts \u2014 estimates from local Claude Code session logs";
|
|
374
485
|
const nextNote = config.agent.backend === "dry-run" ? "previews the next task; nothing is executed or spent" : `previews the next task (live execution ships in a future release; ${cmd("run-once --live")} currently validates and refuses)`;
|
|
486
|
+
const stopLine = `\`afterburner schedule uninstall\` (or Ctrl-C for watch); ${cmd("log")} lists every past run`;
|
|
487
|
+
if (opts.summary) {
|
|
488
|
+
return `${section(emoji.flame, "Ready")}
|
|
489
|
+
Config: ${configPath}
|
|
490
|
+
${opts.summary}
|
|
491
|
+
Engine: ${engineLine}
|
|
492
|
+
Stop: ${stopLine}
|
|
493
|
+
|
|
494
|
+
${next}
|
|
495
|
+
${nextCmd(cmd("run-once --dry-run"), nextNote)}`;
|
|
496
|
+
}
|
|
375
497
|
const headroomClause = `weekly headroom drops below ${config.budget.minWeeklyHeadroomPct}%`;
|
|
376
498
|
const gateLine = config.budget.requireSessionAvailable ? `runs skip themselves when no 5-hour session window is free or ${headroomClause}` : `runs skip themselves when ${headroomClause}`;
|
|
377
499
|
return `${section(emoji.flame, "Ready")}
|
|
@@ -379,11 +501,11 @@ ${nextCmd(cmd("doctor"))}`;
|
|
|
379
501
|
Repos: ${repoLine}
|
|
380
502
|
Engine: ${engineLine}
|
|
381
503
|
Budget: ${budgetLine}
|
|
382
|
-
Limits: at most one task per
|
|
504
|
+
Limits: at most one task per run, capped at ${config.agent.maxTaskTokens.toLocaleString("en-US")} tokens, delivered as one PR on its own branch
|
|
383
505
|
Gate: ${gateLine}
|
|
384
506
|
Safety: live PRs need BOTH a live engine in the config AND --live (two-part opt-in)
|
|
385
507
|
Runs: only when you start them \u2014 \`afterburner schedule install\` (recommended OS scheduler; cron '${config.schedule.cron}' ${config.schedule.timezone}) or \`afterburner watch\` (foreground) make it recurring
|
|
386
|
-
Stop:
|
|
508
|
+
Stop: ${stopLine}
|
|
387
509
|
|
|
388
510
|
${next}
|
|
389
511
|
${nextCmd(cmd("run-once --dry-run"), nextNote)}`;
|
package/dist/core/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pimmesz/afterburner",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "Convert idle Claude subscription quota into shippable engineering work: budget-aware trigger, bounded task selection, PR-only output.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"publishConfig": {
|