@pimmesz/afterburner 1.0.11 → 1.0.12

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.
@@ -148,6 +148,10 @@ var scheduleConfigSchema = z.object({
148
148
  cron: z.string().min(1).default("17 */4 * * *"),
149
149
  timezone: z.string().min(1).default("UTC")
150
150
  }).prefault({});
151
+ var notifyConfigSchema = z.object({
152
+ /** Desktop banner per completed run, opt-in. The PR stays the primary signal. */
153
+ desktop: z.boolean().default(false)
154
+ }).prefault({});
151
155
  var agentConfigSchema = z.object({
152
156
  backend: z.enum(["dry-run", "claude-code", "api-key"]).default("dry-run"),
153
157
  modelByCategory: z.partialRecord(categoryEnum, modelId).default({}).transform((m) => ({ ...DEFAULT_MODEL_BY_CATEGORY, ...m })),
@@ -174,6 +178,7 @@ var configSchema = z.object({
174
178
  budget: budgetConfigSchema,
175
179
  schedule: scheduleConfigSchema,
176
180
  agent: agentConfigSchema,
181
+ notify: notifyConfigSchema,
177
182
  taskCategories: taskCategoriesConfigSchema
178
183
  }).superRefine((config, ctx) => {
179
184
  for (const [category, model] of Object.entries(config.agent.modelByCategory)) {
@@ -693,6 +698,85 @@ function systemdQuote(value) {
693
698
  return /[\s"']/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
694
699
  }
695
700
 
701
+ // src/core/notify/desktop.ts
702
+ import { execFile } from "child_process";
703
+ import { promisify } from "util";
704
+ var run = promisify(execFile);
705
+ var EXEC_TIMEOUT_MS = 1e4;
706
+ var DesktopNotifier = class {
707
+ exec;
708
+ has;
709
+ platform;
710
+ constructor(deps = {}) {
711
+ this.exec = deps.exec ?? ((command, args, opts) => run(command, args, { timeout: EXEC_TIMEOUT_MS, ...opts }));
712
+ this.has = deps.has ?? commandExists;
713
+ this.platform = deps.platform ?? process.platform;
714
+ }
715
+ async notify(record) {
716
+ const title = "Afterburner";
717
+ const subtitle = `${shortRepo(record.repoUrl)} \xB7 ${record.outcome}`;
718
+ const body = record.title;
719
+ try {
720
+ switch (this.platform) {
721
+ case "darwin":
722
+ await this.notifyMac(title, subtitle, body, record.prUrl);
723
+ break;
724
+ case "linux":
725
+ await this.notifyLinux(title, `${subtitle} \u2014 ${body}`);
726
+ break;
727
+ case "win32":
728
+ await this.notifyWindows(title, `${subtitle} \u2014 ${body}`);
729
+ break;
730
+ }
731
+ } catch {
732
+ }
733
+ }
734
+ async notifyMac(title, subtitle, body, prUrl) {
735
+ if (await this.has("terminal-notifier")) {
736
+ const args = ["-title", title, "-subtitle", subtitle, "-message", body, "-sound", "default"];
737
+ if (prUrl) args.push("-open", prUrl);
738
+ await this.exec("terminal-notifier", args);
739
+ return;
740
+ }
741
+ const script = 'display notification (system attribute "AB_BODY") with title (system attribute "AB_TITLE") subtitle (system attribute "AB_SUBTITLE")';
742
+ await this.exec("osascript", ["-e", script], {
743
+ env: { ...process.env, AB_TITLE: title, AB_SUBTITLE: subtitle, AB_BODY: body }
744
+ });
745
+ }
746
+ async notifyLinux(title, body) {
747
+ await this.exec("notify-send", ["--app-name=afterburner", "--", title, body]);
748
+ }
749
+ async notifyWindows(title, body) {
750
+ const aumid = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe";
751
+ const script = [
752
+ "[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]>$null",
753
+ "[Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom,ContentType=WindowsRuntime]>$null",
754
+ "$t=[System.Security.SecurityElement]::Escape($env:AB_TITLE)",
755
+ "$b=[System.Security.SecurityElement]::Escape($env:AB_BODY)",
756
+ '$x="<toast><visual><binding template=`"ToastGeneric`"><text>$t</text><text>$b</text></binding></visual></toast>"',
757
+ "$d=[Windows.Data.Xml.Dom.XmlDocument]::new();$d.LoadXml($x)",
758
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${aumid}').Show([Windows.UI.Notifications.ToastNotification]::new($d))`
759
+ ].join(";");
760
+ await this.exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
761
+ env: { ...process.env, AB_TITLE: title, AB_BODY: body }
762
+ });
763
+ }
764
+ };
765
+ function shortRepo(repoUrl) {
766
+ const trimmed = repoUrl.replace(/[/\\]+$/, "");
767
+ const last = trimmed.split(/[/\\]/).pop() ?? trimmed;
768
+ return last.replace(/\.git$/, "") || trimmed;
769
+ }
770
+ async function commandExists(command) {
771
+ const finder = process.platform === "win32" ? "where" : "which";
772
+ try {
773
+ await run(finder, [command]);
774
+ return true;
775
+ } catch {
776
+ return false;
777
+ }
778
+ }
779
+
696
780
  // src/core/store/run-store.ts
697
781
  import { appendFile, mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
698
782
  import { dirname as dirname2 } from "path";
@@ -734,6 +818,26 @@ var ConsoleNotifier = class {
734
818
  }
735
819
  };
736
820
 
821
+ // src/core/notify/factory.ts
822
+ var CompositeNotifier = class {
823
+ constructor(notifiers) {
824
+ this.notifiers = notifiers;
825
+ }
826
+ notifiers;
827
+ async notify(record) {
828
+ for (const notifier of this.notifiers) {
829
+ try {
830
+ await notifier.notify(record);
831
+ } catch {
832
+ }
833
+ }
834
+ }
835
+ };
836
+ function createNotifier(config) {
837
+ const consoleNotifier = new ConsoleNotifier();
838
+ return config.notify.desktop ? new CompositeNotifier([consoleNotifier, new DesktopNotifier()]) : consoleNotifier;
839
+ }
840
+
737
841
  // src/core/orchestrator.ts
738
842
  async function runOnce(deps) {
739
843
  const { config } = deps;
@@ -1115,8 +1219,12 @@ export {
1115
1219
  scheduleArtifactPaths,
1116
1220
  generateScheduleArtifacts,
1117
1221
  looksRemoteRepoUrl,
1222
+ DesktopNotifier,
1223
+ commandExists,
1118
1224
  JsonlRunStore,
1119
1225
  ConsoleNotifier,
1226
+ CompositeNotifier,
1227
+ createNotifier,
1120
1228
  runOnce,
1121
1229
  ApiKeyRunner,
1122
1230
  taskFingerprint,
package/dist/cli/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- ConsoleNotifier,
4
3
  JsonlRunStore,
5
4
  ManualBudgetProvider,
6
5
  budgetFromUsageCache,
7
6
  claudeConfigDir,
7
+ commandExists,
8
8
  createBudgetProvider,
9
+ createNotifier,
9
10
  createRunner,
10
11
  createSelector,
11
12
  dataDir,
@@ -24,7 +25,7 @@ import {
24
25
  shouldIgnite,
25
26
  startWatch,
26
27
  writeUsageCache
27
- } from "../chunk-Z3H5GIPM.js";
28
+ } from "../chunk-4TD2KS3A.js";
28
29
  import {
29
30
  MCP_STUB_MESSAGE
30
31
  } from "../chunk-2NSOEZWY.js";
@@ -35,7 +36,7 @@ import { Command } from "commander";
35
36
  // package.json
36
37
  var package_default = {
37
38
  name: "@pimmesz/afterburner",
38
- version: "1.0.11",
39
+ version: "1.0.12",
39
40
  description: "Convert idle Claude subscription quota into shippable engineering work: budget-aware trigger, bounded task selection, PR-only output.",
40
41
  license: "Apache-2.0",
41
42
  publishConfig: {
@@ -277,9 +278,9 @@ function resolveCliEntry() {
277
278
 
278
279
  // src/cli/commands/doctor.ts
279
280
  function registerDoctor(program2, packageInfo2) {
280
- program2.command("doctor").description("Check prerequisites and configuration; every failure prints an actionable fix").option("--config <path>", "path to a config file").option("--check-updates", "query the npm registry for the latest published version").action(
281
- (opts) => runDoctor({ packageInfo: packageInfo2, configPath: opts.config, checkUpdates: opts.checkUpdates })
282
- );
281
+ program2.command("doctor").description("Check prerequisites and configuration; every failure prints an actionable fix").option("--config <path>", "path to a config file").option("--check-updates", "query the npm registry for the latest published version").action(async (opts) => {
282
+ await runDoctor({ packageInfo: packageInfo2, configPath: opts.config, checkUpdates: opts.checkUpdates });
283
+ });
283
284
  }
284
285
  async function runDoctor(opts) {
285
286
  console.log(`${deco(emoji.fuel)}${bold("afterburner doctor")}`);
@@ -325,6 +326,7 @@ async function runDoctor(opts) {
325
326
  results.push(checkRepos(config, configPath));
326
327
  results.push(...checkBackend(config));
327
328
  results.push(await checkBudgetProvider(config));
329
+ if (config.notify.desktop) results.push(checkNotify());
328
330
  }
329
331
  results.push(await checkRunStoreWritable());
330
332
  let failures = 0;
@@ -358,6 +360,7 @@ ${renderDoctorNextSteps({
358
360
  })}`
359
361
  );
360
362
  process.exitCode = failures === 0 ? 0 : 1;
363
+ return { ok: failures === 0, config, configPath };
361
364
  }
362
365
  async function buildRunSummary(config) {
363
366
  let budget = null;
@@ -414,10 +417,10 @@ function renderWhen(opts) {
414
417
  const cron = config.schedule.cron;
415
418
  const cadence = describeCadence(cron);
416
419
  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}.`;
420
+ return `no afterburner schedule is installed \u2014 it only runs when you start it; \`afterburner schedule install\` would run it ${cadence} ${config.schedule.timezone}.`;
418
421
  }
419
422
  if (opts.scheduleInstalled === "unknown") {
420
- return `${cadence}, once the entries from \`afterburner schedule install\` are active (not auto-detectable on this platform).`;
423
+ return `${cadence} ${config.schedule.timezone}, once the entries from \`afterburner schedule install\` are active (not auto-detectable on this platform).`;
421
424
  }
422
425
  const zone = opts.scheduleKind === "systemd-user" ? config.schedule.timezone : opts.systemTimezone;
423
426
  const kindLabel = opts.scheduleKind === "systemd-user" ? "systemd" : "launchd";
@@ -446,12 +449,12 @@ function describeCadence(cron) {
446
449
  } catch {
447
450
  return `on cron '${cron}'`;
448
451
  }
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
452
  const mm = String(minute).padStart(2, "0");
453
+ if (hours.length === 24) return `every hour at :${mm}`;
454
454
  const times = hours.map((h) => `${String(h).padStart(2, "0")}:${mm}`);
455
+ const step2 = (hours[1] ?? 0) - (hours[0] ?? 0);
456
+ const uniform = hours.length > 1 && hours[0] === 0 && 24 % step2 === 0 && hours.length === 24 / step2 && hours.every((h, i) => h === i * step2);
457
+ if (uniform) return `every ${step2} hours, at ${times.join(", ")}`;
455
458
  return `daily at ${times.join(", ")}`;
456
459
  }
457
460
  function renderDoctorNextSteps(opts) {
@@ -751,6 +754,42 @@ function formatAge(ms) {
751
754
  if (minutes < 60) return `${minutes}m`;
752
755
  return `${Math.round(minutes / 60)}h`;
753
756
  }
757
+ function checkNotify(deps = {}) {
758
+ const platform = deps.platform ?? process.platform;
759
+ const has = deps.has ?? commandExistsSync;
760
+ if (platform === "darwin") {
761
+ return has("terminal-notifier") ? {
762
+ name: "notify",
763
+ ok: true,
764
+ detail: "desktop banners via terminal-notifier (clickable PR links)"
765
+ } : {
766
+ name: "notify",
767
+ ok: true,
768
+ detail: "desktop banners via osascript (banners only, not clickable); `brew install terminal-notifier` adds clickable PR links"
769
+ };
770
+ }
771
+ if (platform === "linux") {
772
+ return has("notify-send") ? { name: "notify", ok: true, detail: "desktop banners via notify-send" } : {
773
+ name: "notify",
774
+ ok: false,
775
+ detail: "desktop notifications enabled but notify-send is not on PATH",
776
+ fix: "Install libnotify (Debian/Ubuntu: `apt install libnotify-bin`, Fedora: `dnf install libnotify`), or set notify.desktop: false."
777
+ };
778
+ }
779
+ if (platform === "win32") {
780
+ return { name: "notify", ok: true, detail: "desktop banners via PowerShell toast" };
781
+ }
782
+ return {
783
+ name: "notify",
784
+ ok: false,
785
+ detail: `desktop notifications enabled but no native backend exists on ${platform}`,
786
+ fix: "Set notify.desktop: false; this platform has no desktop banner support."
787
+ };
788
+ }
789
+ function commandExistsSync(command) {
790
+ const finder = process.platform === "win32" ? "where" : "which";
791
+ return spawnSync(finder, [command], { stdio: "ignore" }).status === 0;
792
+ }
754
793
  function classifyClaudeAuth(exitStatus, stdout) {
755
794
  if (exitStatus !== 0) {
756
795
  return {
@@ -798,11 +837,91 @@ async function checkRunStoreWritable() {
798
837
  }
799
838
 
800
839
  // src/cli/commands/init.ts
801
- import { existsSync as existsSync3, statSync as statSync2 } from "fs";
802
- import { writeFile as writeFile2 } from "fs/promises";
840
+ import { spawn } from "child_process";
841
+ import { existsSync as existsSync4, statSync as statSync2 } from "fs";
842
+ import { writeFile as writeFile3 } from "fs/promises";
803
843
  import { createInterface } from "readline/promises";
804
844
  import { homedir } from "os";
805
- import { join as join2, resolve as resolve3 } from "path";
845
+ import { join as join2, resolve as resolve4 } from "path";
846
+
847
+ // src/cli/commands/schedule.ts
848
+ import { mkdir as mkdir2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
849
+ import { existsSync as existsSync3 } from "fs";
850
+ import { dirname as dirname2, resolve as resolve3 } from "path";
851
+ function currentPlatform() {
852
+ const platform = process.platform;
853
+ if (platform === "darwin" || platform === "linux" || platform === "win32") return platform;
854
+ fail(
855
+ `Unsupported platform "${platform}" for native scheduling. Use \`afterburner watch\` instead.`
856
+ );
857
+ }
858
+ async function performScheduleInstall(platform, configPath, config) {
859
+ const artifacts = generateScheduleArtifacts(platform, {
860
+ cron: config.schedule.cron,
861
+ timezone: config.schedule.timezone,
862
+ nodePath: process.execPath,
863
+ cliPath: resolveCliEntry(),
864
+ configPath
865
+ });
866
+ const written = [];
867
+ for (const file of artifacts.files) {
868
+ await mkdir2(dirname2(file.path), { recursive: true });
869
+ await writeFile2(file.path, file.content, "utf8");
870
+ written.push(file.path);
871
+ }
872
+ return {
873
+ kind: artifacts.kind,
874
+ written,
875
+ activationHint: artifacts.activationHint,
876
+ removalHint: artifacts.removalHint
877
+ };
878
+ }
879
+ function registerSchedule(program2) {
880
+ const schedule = program2.command("schedule").description("Install or remove a native OS scheduler entry (launchd / systemd / schtasks)");
881
+ schedule.command("install").description("Generate and install the native scheduler entry for this OS").option("--config <path>", "path to a config file (recorded in the scheduled command)").action(async (opts) => {
882
+ const { config, filepath } = await loadConfigOrExit(opts.config);
883
+ const configPath = opts.config ? resolve3(opts.config) : filepath;
884
+ const result = await performScheduleInstall(currentPlatform(), configPath, config);
885
+ for (const path of result.written) console.log(`Wrote ${path}`);
886
+ console.log(`
887
+ Kind: ${result.kind}`);
888
+ console.log(`Scheduled runs will use this config: ${configPath}`);
889
+ console.log(`Activate with:
890
+ ${result.activationHint}`);
891
+ console.log(`
892
+ Remove later with:
893
+ ${result.removalHint}`);
894
+ });
895
+ schedule.command("uninstall").description("Remove the native scheduler entry files for this OS").option("--config <path>", "path to a config file").action(async (opts) => {
896
+ const { config } = await loadConfigOrExit(opts.config);
897
+ const artifacts = generateScheduleArtifacts(currentPlatform(), {
898
+ cron: config.schedule.cron,
899
+ timezone: config.schedule.timezone,
900
+ nodePath: process.execPath,
901
+ cliPath: resolveCliEntry()
902
+ });
903
+ if (artifacts.files.length === 0) {
904
+ console.log(
905
+ `Nothing to delete on this platform. Remove the tasks with:
906
+ ${artifacts.removalHint}`
907
+ );
908
+ return;
909
+ }
910
+ for (const file of artifacts.files) {
911
+ if (existsSync3(file.path)) {
912
+ await rm2(file.path);
913
+ console.log(`Removed ${file.path}`);
914
+ } else {
915
+ console.log(`Not found (already removed?): ${file.path}`);
916
+ }
917
+ }
918
+ console.log(`
919
+ Finish deactivation with:
920
+ ${artifacts.removalHint}`);
921
+ });
922
+ }
923
+
924
+ // src/cli/commands/init.ts
806
925
  var BACKENDS = ["dry-run", "claude-code", "api-key"];
807
926
  var BUDGET_PROVIDERS = ["manual", "claude-usage", "claude-code-transcripts"];
808
927
  var ENGINE_CHOICES = {
@@ -863,12 +982,18 @@ async function runInit(opts, packageInfo2) {
863
982
  }
864
983
  const { backend, budgetProvider, repoUrl, verifyNow, targetDir, target } = answers;
865
984
  assertCanWriteConfig(targetDir, target, opts.force === true || answers.overwriteConsented);
866
- await writeFile2(target, renderConfig(backend, budgetProvider, repoUrl), "utf8");
985
+ await writeFile3(target, renderConfig(backend, budgetProvider, repoUrl), "utf8");
867
986
  console.log(`
868
987
  ${step("Config", target)}`);
869
988
  if (verifyNow) {
870
989
  console.log();
871
- await runDoctor({ packageInfo: packageInfo2, configPath: target });
990
+ const { ok, config, configPath } = await runDoctor({ packageInfo: packageInfo2, configPath: target });
991
+ if (!opts.yes && ok && config && configPath) {
992
+ await offerScheduleInstall(configPath, config);
993
+ if (await offerDesktopNotifications()) {
994
+ await writeFile3(target, renderConfig(backend, budgetProvider, repoUrl, true), "utf8");
995
+ }
996
+ }
872
997
  return;
873
998
  }
874
999
  console.log(
@@ -882,6 +1007,118 @@ ${renderOnboardingSummary({
882
1007
  })}`
883
1008
  );
884
1009
  }
1010
+ async function offerScheduleInstall(configPath, config, deps = {}) {
1011
+ const platform = deps.platform ?? process.platform;
1012
+ if (platform !== "darwin" && platform !== "linux" && platform !== "win32") {
1013
+ return false;
1014
+ }
1015
+ const created = deps.rl ? null : createInterface({ input: process.stdin, output: process.stdout });
1016
+ created?.on("SIGINT", () => created.close());
1017
+ const rl = deps.rl ?? created;
1018
+ try {
1019
+ const answer = (await rl.question("\nInstall the schedule now, so it runs unattended? [y/N]: ")).trim().toLowerCase();
1020
+ if (answer !== "y" && answer !== "yes") {
1021
+ console.log(
1022
+ dim("Skipped \u2014 run `afterburner schedule install` whenever you want it recurring.")
1023
+ );
1024
+ return false;
1025
+ }
1026
+ const install2 = deps.install ?? performScheduleInstall;
1027
+ const result = await install2(platform, configPath, config);
1028
+ for (const path of result.written) console.log(` ${green("\u2713")} wrote ${path}`);
1029
+ console.log(
1030
+ `
1031
+ ${bold("One more step \u2014 turn it on")} (the entry is written but not yet active):
1032
+ ${result.activationHint}`
1033
+ );
1034
+ console.log(dim("\nStop it later with `afterburner schedule uninstall`."));
1035
+ return true;
1036
+ } catch (error) {
1037
+ if (error?.code === "ABORT_ERR") {
1038
+ console.log(dim("\nSkipped scheduling."));
1039
+ return false;
1040
+ }
1041
+ console.log(
1042
+ errYellow(
1043
+ `Could not install a native schedule: ${error instanceof Error ? error.message : String(error)}. Run \`afterburner schedule install\` later, or use \`afterburner watch\`.`
1044
+ )
1045
+ );
1046
+ return false;
1047
+ } finally {
1048
+ created?.close();
1049
+ }
1050
+ }
1051
+ async function offerDesktopNotifications(deps = {}) {
1052
+ const platform = deps.platform ?? process.platform;
1053
+ const created = deps.rl ? null : createInterface({ input: process.stdin, output: process.stdout });
1054
+ created?.on("SIGINT", () => created.close());
1055
+ const rl = deps.rl ?? created;
1056
+ try {
1057
+ const answer = (await rl.question("\nSend a desktop notification when a run completes? [y/N]: ")).trim().toLowerCase();
1058
+ if (answer !== "y" && answer !== "yes") {
1059
+ console.log(dim("Skipped \u2014 set notify.desktop: true in the config to enable later."));
1060
+ return false;
1061
+ }
1062
+ const has = deps.has ?? commandExists;
1063
+ if (platform === "darwin") {
1064
+ if (!await has("terminal-notifier") && await has("brew")) {
1065
+ const sub = (await rl.question(
1066
+ "Install terminal-notifier for clickable banners (brew install terminal-notifier)? [y/N]: "
1067
+ )).trim().toLowerCase();
1068
+ if (sub === "y" || sub === "yes") {
1069
+ try {
1070
+ console.log(dim("Installing terminal-notifier\u2026"));
1071
+ await (deps.install ?? brewInstall)("terminal-notifier");
1072
+ console.log(` ${green("\u2713")} installed terminal-notifier`);
1073
+ } catch (error) {
1074
+ console.log(
1075
+ errYellow(
1076
+ `Could not install terminal-notifier: ${error instanceof Error ? error.message : String(error)}. Banners will use osascript (no click) until you install it.`
1077
+ )
1078
+ );
1079
+ }
1080
+ }
1081
+ }
1082
+ }
1083
+ const backendReady = platform === "darwin" || platform === "win32" ? true : platform === "linux" ? await has("notify-send") : false;
1084
+ if (backendReady) {
1085
+ console.log(dim("Desktop notifications on \u2014 a banner fires when a run completes."));
1086
+ } else if (platform === "linux") {
1087
+ console.log(
1088
+ errYellow(
1089
+ "Desktop notifications enabled, but notify-send is not on PATH \u2014 install libnotify (Debian/Ubuntu: `apt install libnotify-bin`) for banners to appear."
1090
+ )
1091
+ );
1092
+ } else {
1093
+ console.log(
1094
+ errYellow(
1095
+ `Desktop notifications enabled, but ${platform} has no desktop banner backend; runs still log to the console and run store.`
1096
+ )
1097
+ );
1098
+ }
1099
+ return true;
1100
+ } catch (error) {
1101
+ if (error?.code === "ABORT_ERR") return false;
1102
+ console.log(
1103
+ errYellow(
1104
+ `Could not set up notifications: ${error instanceof Error ? error.message : String(error)}.`
1105
+ )
1106
+ );
1107
+ return false;
1108
+ } finally {
1109
+ created?.close();
1110
+ }
1111
+ }
1112
+ function brewInstall(formula) {
1113
+ return new Promise((resolve7, reject) => {
1114
+ const child = spawn("brew", ["install", formula], { stdio: "inherit" });
1115
+ child.on("error", reject);
1116
+ child.on(
1117
+ "close",
1118
+ (code) => code === 0 ? resolve7() : reject(new Error(`brew install exited with code ${code}`))
1119
+ );
1120
+ });
1121
+ }
885
1122
  async function collectAnswers(rl, opts, cwd = process.cwd()) {
886
1123
  let targetDir = cwd;
887
1124
  let target = join2(targetDir, "afterburner.config.mjs");
@@ -896,7 +1133,7 @@ async function collectAnswers(rl, opts, cwd = process.cwd()) {
896
1133
  console.log(step("Engine", backend));
897
1134
  console.log(`
898
1135
  ${bold("Step 2 of 3 \u2014 Repository (the allowlist of what it may touch)")}`);
899
- const detectedRepo = existsSync3(join2(cwd, ".git")) ? cwd : null;
1136
+ const detectedRepo = existsSync4(join2(cwd, ".git")) ? cwd : null;
900
1137
  if (detectedRepo) {
901
1138
  console.log(` - press Enter to use this folder (a git repo): ${detectedRepo}`);
902
1139
  }
@@ -965,7 +1202,7 @@ function assertCanWriteConfig(targetDir, target, force) {
965
1202
  }
966
1203
  function configConflict(targetDir, target) {
967
1204
  const existing = CONFIG_FILENAMES.map((name) => join2(targetDir, name)).filter(
968
- (p) => existsSync3(p)
1205
+ (p) => existsSync4(p)
969
1206
  );
970
1207
  return {
971
1208
  exact: existing.includes(target) ? target : null,
@@ -986,7 +1223,7 @@ Overwrite it with these new answers? Only this file changes \u2014 your repo is
986
1223
  return answer === "y" || answer === "yes";
987
1224
  }
988
1225
  function hasConfig(dir) {
989
- return CONFIG_FILENAMES.some((name) => existsSync3(join2(dir, name)));
1226
+ return CONFIG_FILENAMES.some((name) => existsSync4(join2(dir, name)));
990
1227
  }
991
1228
  async function askChoice(rl, prompt, count) {
992
1229
  for (; ; ) {
@@ -999,7 +1236,7 @@ async function askChoice(rl, prompt, count) {
999
1236
  function localRepoPath(repoUrl, base = process.cwd()) {
1000
1237
  if (repoUrl === "" || looksRemoteRepoUrl(repoUrl)) return null;
1001
1238
  const expanded = repoUrl === "~" ? homedir() : repoUrl.replace(/^~(?=\/)/, homedir());
1002
- const absolute = resolve3(base, expanded);
1239
+ const absolute = resolve4(base, expanded);
1003
1240
  return statSync2(absolute, { throwIfNoEntry: false })?.isDirectory() ? absolute : null;
1004
1241
  }
1005
1242
  function renderOnboardingSummary(opts) {
@@ -1025,7 +1262,7 @@ function renderOnboardingSummary(opts) {
1025
1262
  );
1026
1263
  return lines.join("\n");
1027
1264
  }
1028
- function renderConfig(backend, budgetProvider, repoUrl) {
1265
+ function renderConfig(backend, budgetProvider, repoUrl, desktopNotify = false) {
1029
1266
  const repoBlock = repoUrl ? ` {
1030
1267
  url: ${JSON.stringify(repoUrl)},
1031
1268
  defaultBranch: 'main',
@@ -1075,6 +1312,11 @@ ${repoBlock}
1075
1312
  // money at API rates outside promotional windows.
1076
1313
  allowFable: false,
1077
1314
  },
1315
+ notify: {
1316
+ // Desktop banner when a run completes (macOS/Linux/Windows). The opened PR
1317
+ // is still the primary notification; this is a local convenience.
1318
+ desktop: ${desktopNotify},
1319
+ },
1078
1320
  };
1079
1321
 
1080
1322
  export default config;
@@ -1174,7 +1416,7 @@ function registerRunOnce(program2) {
1174
1416
  selector: createSelector(config),
1175
1417
  runner,
1176
1418
  store: new JsonlRunStore(),
1177
- notifier: new ConsoleNotifier()
1419
+ notifier: createNotifier(config)
1178
1420
  });
1179
1421
  if (outcomes.some((o) => o.status === "completed")) await ignite();
1180
1422
  for (const outcome of outcomes) {
@@ -1207,72 +1449,6 @@ ${section(emoji.rocket, "Next")}`);
1207
1449
  });
1208
1450
  }
1209
1451
 
1210
- // src/cli/commands/schedule.ts
1211
- import { mkdir as mkdir2, rm as rm2, writeFile as writeFile3 } from "fs/promises";
1212
- import { existsSync as existsSync4 } from "fs";
1213
- import { dirname as dirname2, resolve as resolve4 } from "path";
1214
- function currentPlatform() {
1215
- const platform = process.platform;
1216
- if (platform === "darwin" || platform === "linux" || platform === "win32") return platform;
1217
- fail(
1218
- `Unsupported platform "${platform}" for native scheduling. Use \`afterburner watch\` instead.`
1219
- );
1220
- }
1221
- function registerSchedule(program2) {
1222
- const schedule = program2.command("schedule").description("Install or remove a native OS scheduler entry (launchd / systemd / schtasks)");
1223
- schedule.command("install").description("Generate and install the native scheduler entry for this OS").option("--config <path>", "path to a config file (recorded in the scheduled command)").action(async (opts) => {
1224
- const { config, filepath } = await loadConfigOrExit(opts.config);
1225
- const configPath = opts.config ? resolve4(opts.config) : filepath;
1226
- const artifacts = generateScheduleArtifacts(currentPlatform(), {
1227
- cron: config.schedule.cron,
1228
- timezone: config.schedule.timezone,
1229
- nodePath: process.execPath,
1230
- cliPath: resolveCliEntry(),
1231
- configPath
1232
- });
1233
- for (const file of artifacts.files) {
1234
- await mkdir2(dirname2(file.path), { recursive: true });
1235
- await writeFile3(file.path, file.content, "utf8");
1236
- console.log(`Wrote ${file.path}`);
1237
- }
1238
- console.log(`
1239
- Kind: ${artifacts.kind}`);
1240
- console.log(`Scheduled runs will use this config: ${configPath}`);
1241
- console.log(`Activate with:
1242
- ${artifacts.activationHint}`);
1243
- console.log(`
1244
- Remove later with:
1245
- ${artifacts.removalHint}`);
1246
- });
1247
- schedule.command("uninstall").description("Remove the native scheduler entry files for this OS").option("--config <path>", "path to a config file").action(async (opts) => {
1248
- const { config } = await loadConfigOrExit(opts.config);
1249
- const artifacts = generateScheduleArtifacts(currentPlatform(), {
1250
- cron: config.schedule.cron,
1251
- timezone: config.schedule.timezone,
1252
- nodePath: process.execPath,
1253
- cliPath: resolveCliEntry()
1254
- });
1255
- if (artifacts.files.length === 0) {
1256
- console.log(
1257
- `Nothing to delete on this platform. Remove the tasks with:
1258
- ${artifacts.removalHint}`
1259
- );
1260
- return;
1261
- }
1262
- for (const file of artifacts.files) {
1263
- if (existsSync4(file.path)) {
1264
- await rm2(file.path);
1265
- console.log(`Removed ${file.path}`);
1266
- } else {
1267
- console.log(`Not found (already removed?): ${file.path}`);
1268
- }
1269
- }
1270
- console.log(`
1271
- Finish deactivation with:
1272
- ${artifacts.removalHint}`);
1273
- });
1274
- }
1275
-
1276
1452
  // src/cli/commands/skill.ts
1277
1453
  import { copyFile, mkdir as mkdir3 } from "fs/promises";
1278
1454
  import { existsSync as existsSync5 } from "fs";
@@ -1302,7 +1478,7 @@ function registerSkill(program2) {
1302
1478
  }
1303
1479
 
1304
1480
  // src/cli/commands/statusline.ts
1305
- import { spawn } from "child_process";
1481
+ import { spawn as spawn2 } from "child_process";
1306
1482
  import { existsSync as existsSync6 } from "fs";
1307
1483
  import { copyFile as copyFile2, mkdir as mkdir4, readFile, rm as rm3, writeFile as writeFile4 } from "fs/promises";
1308
1484
  import { dirname as dirname3, join as join4 } from "path";
@@ -1377,7 +1553,7 @@ async function readWrappedState() {
1377
1553
  }
1378
1554
  function passThrough(command, input) {
1379
1555
  return new Promise((resolve7) => {
1380
- const child = spawn(command, { shell: true, stdio: ["pipe", "inherit", "inherit"] });
1556
+ const child = spawn2(command, { shell: true, stdio: ["pipe", "inherit", "inherit"] });
1381
1557
  child.on("error", () => resolve7());
1382
1558
  child.on("close", () => resolve7());
1383
1559
  child.stdin.on("error", () => {
@@ -1697,7 +1873,7 @@ function registerWatch(program2) {
1697
1873
  selector: createSelector(config),
1698
1874
  runner,
1699
1875
  store: new JsonlRunStore(),
1700
- notifier: new ConsoleNotifier()
1876
+ notifier: createNotifier(config)
1701
1877
  });
1702
1878
  for (const outcome of outcomes) {
1703
1879
  console.log(`[afterburner] ${outcome.repoUrl}: ${outcome.status}, ${outcome.reason}`);
@@ -53,8 +53,8 @@ declare const budgetConfigSchema: z.ZodPrefault<z.ZodObject<{
53
53
  }, z.core.$strip>>;
54
54
  declare const agentConfigSchema: z.ZodPrefault<z.ZodObject<{
55
55
  backend: z.ZodDefault<z.ZodEnum<{
56
- "claude-code": "claude-code";
57
56
  "dry-run": "dry-run";
57
+ "claude-code": "claude-code";
58
58
  "api-key": "api-key";
59
59
  }>>;
60
60
  modelByCategory: z.ZodPipe<z.ZodDefault<z.ZodRecord<z.ZodEnum<{
@@ -115,8 +115,8 @@ declare const configSchema: z.ZodObject<{
115
115
  }, z.core.$strip>>;
116
116
  agent: z.ZodPrefault<z.ZodObject<{
117
117
  backend: z.ZodDefault<z.ZodEnum<{
118
- "claude-code": "claude-code";
119
118
  "dry-run": "dry-run";
119
+ "claude-code": "claude-code";
120
120
  "api-key": "api-key";
121
121
  }>>;
122
122
  modelByCategory: z.ZodPipe<z.ZodDefault<z.ZodRecord<z.ZodEnum<{
@@ -139,6 +139,9 @@ declare const configSchema: z.ZodObject<{
139
139
  maxTaskTokens: z.ZodDefault<z.ZodNumber>;
140
140
  allowFable: z.ZodDefault<z.ZodBoolean>;
141
141
  }, z.core.$strip>>;
142
+ notify: z.ZodPrefault<z.ZodObject<{
143
+ desktop: z.ZodDefault<z.ZodBoolean>;
144
+ }, z.core.$strip>>;
142
145
  taskCategories: z.ZodPipe<z.ZodDefault<z.ZodRecord<z.ZodEnum<{
143
146
  security: "security";
144
147
  tests: "tests";
@@ -683,6 +686,58 @@ declare class ConsoleNotifier implements Notifier {
683
686
  notify(record: RunRecord): Promise<void>;
684
687
  }
685
688
 
689
+ /** Spawn options we actually use; kept narrow so the injected exec is easy to fake. */
690
+ interface ExecOpts {
691
+ env?: NodeJS.ProcessEnv;
692
+ }
693
+ type Exec = (command: string, args: string[], opts?: ExecOpts) => Promise<unknown>;
694
+ interface DesktopNotifierDeps {
695
+ /** Spawns a command (argv array, never a shell string). Injected in tests. */
696
+ exec?: Exec;
697
+ /** Whether a binary is resolvable on PATH. Injected in tests. */
698
+ has?: (command: string) => Promise<boolean>;
699
+ platform?: NodeJS.Platform;
700
+ }
701
+ /**
702
+ * Fires an OS-native desktop banner per completed run, with zero npm
703
+ * dependencies — it shells out to platform tools: terminal-notifier (clickable)
704
+ * or osascript on macOS, notify-send on Linux, a PowerShell toast on Windows.
705
+ *
706
+ * Two invariants:
707
+ * 1. Untrusted record text (title, repo) is passed as argv or via environment
708
+ * variables, NEVER interpolated into a shell string or a second interpreter's
709
+ * source. osascript evaluates AppleScript and PowerShell evaluates a script,
710
+ * so their dynamic text comes from env (`system attribute` / `$env:`), which
711
+ * closes the AppleScript/script-injection hole an argv array alone leaves open.
712
+ * 2. notify() never throws: a missing tool, denied permission, or headless box
713
+ * must not break a run. The run store stays the source of truth.
714
+ */
715
+ declare class DesktopNotifier implements Notifier {
716
+ private readonly exec;
717
+ private readonly has;
718
+ private readonly platform;
719
+ constructor(deps?: DesktopNotifierDeps);
720
+ notify(record: RunRecord): Promise<void>;
721
+ private notifyMac;
722
+ private notifyLinux;
723
+ private notifyWindows;
724
+ }
725
+ /** True when `command` resolves on PATH (via which/where). Never throws. */
726
+ declare function commandExists(command: string): Promise<boolean>;
727
+
728
+ /** Runs several notifiers in order, isolating failures so one can't suppress the rest. */
729
+ declare class CompositeNotifier implements Notifier {
730
+ private readonly notifiers;
731
+ constructor(notifiers: Notifier[]);
732
+ notify(record: RunRecord): Promise<void>;
733
+ }
734
+ /**
735
+ * Picks the notifier from config. ConsoleNotifier ALWAYS runs: its line is the
736
+ * audit trail that reaches the scheduler's stdout log, so a swallowed desktop
737
+ * banner never costs the record. The desktop banner is layered on when opted in.
738
+ */
739
+ declare function createNotifier(config: AfterburnerConfig): Notifier;
740
+
686
741
  interface GateConfig {
687
742
  minWeeklyHeadroomPct: number;
688
743
  safetyMarginTokens: number;
@@ -778,4 +833,4 @@ interface RepoRunOutcome {
778
833
  */
779
834
  declare function runOnce(deps: RunOnceDeps): Promise<RepoRunOutcome[]>;
780
835
 
781
- export { type AfterburnerConfig, type AfterburnerUserConfig, type AgentConfig, type AgentRunner, ApiKeyRunner, BASE_TASK_TOKENS, type Budget, type BudgetConfig, type BudgetProvider, type BudgetProviderOptions, type CandidateTask, type ClaudeCodeInvocation, ClaudeCodeRunner, ClaudeCodeTranscriptsBudgetProvider, type ClaudeCodeTranscriptsOptions, type ClaudeUsageBudgetOptions, ClaudeUsageBudgetProvider, ConsoleNotifier, DEFAULT_MODEL_BY_CATEGORY, type DeterministicSelectorOptions, DeterministicTaskSelector, DryRunRunner, type GateConfig, type GateDecision, JsonlRunStore, type LoadedConfig, ManualBudgetProvider, type ModelCostTable, type ModelWeightEntry, type NativeScheduleOptions, type Notifier, type RateLimitWindow, type RateLimits, type RepoConfig, type RepoRunOutcome, type RunOnceDeps, type RunOutcome, type RunRecord, type RunResult, type RunStore, type RunnerBackend, type ScheduleArtifacts, type SupportedPlatform, TASK_CATEGORIES, TASK_TAXONOMY, type TaskCategory, type TaskCategoryInfo, type TaskIdentity, type TaskSelector, type TranscriptUsageSummary, type UsageCache, type UsageCacheBudgetOptions, type UsageCacheBudgetResult, type WatchHandle, assertModelAllowed, budgetFromUsageCache, buildClaudeCodeInvocation, claudeConfigDir, configDir, configSchema, createBudgetProvider, createModelCostTable, createRunner, createSelector, dataDir, defaultClaudeProjectsDir, defaultCostTable, defaultRunStorePath, defaultUsageCachePath, defineConfig, deriveBranchName, derivePrTitle, formatConfigError, generateScheduleArtifacts, isFableModel, loadConfig, mapConfigLoadError, parseSimpleCron, readUsageCache, repoConfigSchema, runOnce, sanitizeSpawnEnv, shouldIgnite, startWatch, summarizeTranscriptUsage, taskFingerprint, writeUsageCache };
836
+ export { type AfterburnerConfig, type AfterburnerUserConfig, type AgentConfig, type AgentRunner, ApiKeyRunner, BASE_TASK_TOKENS, type Budget, type BudgetConfig, type BudgetProvider, type BudgetProviderOptions, type CandidateTask, type ClaudeCodeInvocation, ClaudeCodeRunner, ClaudeCodeTranscriptsBudgetProvider, type ClaudeCodeTranscriptsOptions, type ClaudeUsageBudgetOptions, ClaudeUsageBudgetProvider, CompositeNotifier, ConsoleNotifier, DEFAULT_MODEL_BY_CATEGORY, DesktopNotifier, type DeterministicSelectorOptions, DeterministicTaskSelector, DryRunRunner, type GateConfig, type GateDecision, JsonlRunStore, type LoadedConfig, ManualBudgetProvider, type ModelCostTable, type ModelWeightEntry, type NativeScheduleOptions, type Notifier, type RateLimitWindow, type RateLimits, type RepoConfig, type RepoRunOutcome, type RunOnceDeps, type RunOutcome, type RunRecord, type RunResult, type RunStore, type RunnerBackend, type ScheduleArtifacts, type SupportedPlatform, TASK_CATEGORIES, TASK_TAXONOMY, type TaskCategory, type TaskCategoryInfo, type TaskIdentity, type TaskSelector, type TranscriptUsageSummary, type UsageCache, type UsageCacheBudgetOptions, type UsageCacheBudgetResult, type WatchHandle, assertModelAllowed, budgetFromUsageCache, buildClaudeCodeInvocation, claudeConfigDir, commandExists, configDir, configSchema, createBudgetProvider, createModelCostTable, createNotifier, createRunner, createSelector, dataDir, defaultClaudeProjectsDir, defaultCostTable, defaultRunStorePath, defaultUsageCachePath, defineConfig, deriveBranchName, derivePrTitle, formatConfigError, generateScheduleArtifacts, isFableModel, loadConfig, mapConfigLoadError, parseSimpleCron, readUsageCache, repoConfigSchema, runOnce, sanitizeSpawnEnv, shouldIgnite, startWatch, summarizeTranscriptUsage, taskFingerprint, writeUsageCache };
@@ -4,8 +4,10 @@ import {
4
4
  ClaudeCodeRunner,
5
5
  ClaudeCodeTranscriptsBudgetProvider,
6
6
  ClaudeUsageBudgetProvider,
7
+ CompositeNotifier,
7
8
  ConsoleNotifier,
8
9
  DEFAULT_MODEL_BY_CATEGORY,
10
+ DesktopNotifier,
9
11
  DeterministicTaskSelector,
10
12
  DryRunRunner,
11
13
  JsonlRunStore,
@@ -16,10 +18,12 @@ import {
16
18
  budgetFromUsageCache,
17
19
  buildClaudeCodeInvocation,
18
20
  claudeConfigDir,
21
+ commandExists,
19
22
  configDir,
20
23
  configSchema,
21
24
  createBudgetProvider,
22
25
  createModelCostTable,
26
+ createNotifier,
23
27
  createRunner,
24
28
  createSelector,
25
29
  dataDir,
@@ -45,15 +49,17 @@ import {
45
49
  summarizeTranscriptUsage,
46
50
  taskFingerprint,
47
51
  writeUsageCache
48
- } from "../chunk-Z3H5GIPM.js";
52
+ } from "../chunk-4TD2KS3A.js";
49
53
  export {
50
54
  ApiKeyRunner,
51
55
  BASE_TASK_TOKENS,
52
56
  ClaudeCodeRunner,
53
57
  ClaudeCodeTranscriptsBudgetProvider,
54
58
  ClaudeUsageBudgetProvider,
59
+ CompositeNotifier,
55
60
  ConsoleNotifier,
56
61
  DEFAULT_MODEL_BY_CATEGORY,
62
+ DesktopNotifier,
57
63
  DeterministicTaskSelector,
58
64
  DryRunRunner,
59
65
  JsonlRunStore,
@@ -64,10 +70,12 @@ export {
64
70
  budgetFromUsageCache,
65
71
  buildClaudeCodeInvocation,
66
72
  claudeConfigDir,
73
+ commandExists,
67
74
  configDir,
68
75
  configSchema,
69
76
  createBudgetProvider,
70
77
  createModelCostTable,
78
+ createNotifier,
71
79
  createRunner,
72
80
  createSelector,
73
81
  dataDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimmesz/afterburner",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
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": {