@pimmesz/afterburner 1.0.11 → 1.0.13

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.13",
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,17 +837,96 @@ 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";
806
- var BACKENDS = ["dry-run", "claude-code", "api-key"];
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
925
+ var OFFERED_BACKENDS = ["dry-run", "claude-code"];
807
926
  var BUDGET_PROVIDERS = ["manual", "claude-usage", "claude-code-transcripts"];
808
927
  var ENGINE_CHOICES = {
809
928
  "dry-run": "simulates everything, spends nothing. The safe default.",
810
- "claude-code": "your Claude subscription via the local `claude` login. No extra money; it spends quota you already pay for.",
811
- "api-key": "bills your Anthropic API account per token. Every run costs real money."
929
+ "claude-code": "your Claude subscription via the local `claude` login. No extra money; it spends quota you already pay for."
812
930
  };
813
931
  var ENGINE_NOTES = {
814
932
  "dry-run": "dry-run (simulates everything, spends nothing)",
@@ -863,12 +981,18 @@ async function runInit(opts, packageInfo2) {
863
981
  }
864
982
  const { backend, budgetProvider, repoUrl, verifyNow, targetDir, target } = answers;
865
983
  assertCanWriteConfig(targetDir, target, opts.force === true || answers.overwriteConsented);
866
- await writeFile2(target, renderConfig(backend, budgetProvider, repoUrl), "utf8");
984
+ await writeFile3(target, renderConfig(backend, budgetProvider, repoUrl), "utf8");
867
985
  console.log(`
868
986
  ${step("Config", target)}`);
869
987
  if (verifyNow) {
870
988
  console.log();
871
- await runDoctor({ packageInfo: packageInfo2, configPath: target });
989
+ const { ok, config, configPath } = await runDoctor({ packageInfo: packageInfo2, configPath: target });
990
+ if (!opts.yes && ok && config && configPath) {
991
+ await offerScheduleInstall(configPath, config);
992
+ if (await offerDesktopNotifications()) {
993
+ await writeFile3(target, renderConfig(backend, budgetProvider, repoUrl, true), "utf8");
994
+ }
995
+ }
872
996
  return;
873
997
  }
874
998
  console.log(
@@ -882,6 +1006,118 @@ ${renderOnboardingSummary({
882
1006
  })}`
883
1007
  );
884
1008
  }
1009
+ async function offerScheduleInstall(configPath, config, deps = {}) {
1010
+ const platform = deps.platform ?? process.platform;
1011
+ if (platform !== "darwin" && platform !== "linux" && platform !== "win32") {
1012
+ return false;
1013
+ }
1014
+ const created = deps.rl ? null : createInterface({ input: process.stdin, output: process.stdout });
1015
+ created?.on("SIGINT", () => created.close());
1016
+ const rl = deps.rl ?? created;
1017
+ try {
1018
+ const answer = (await rl.question("\nInstall the schedule now, so it runs unattended? [y/N]: ")).trim().toLowerCase();
1019
+ if (answer !== "y" && answer !== "yes") {
1020
+ console.log(
1021
+ dim("Skipped \u2014 run `afterburner schedule install` whenever you want it recurring.")
1022
+ );
1023
+ return false;
1024
+ }
1025
+ const install2 = deps.install ?? performScheduleInstall;
1026
+ const result = await install2(platform, configPath, config);
1027
+ for (const path of result.written) console.log(` ${green("\u2713")} wrote ${path}`);
1028
+ console.log(
1029
+ `
1030
+ ${bold("One more step \u2014 turn it on")} (the entry is written but not yet active):
1031
+ ${result.activationHint}`
1032
+ );
1033
+ console.log(dim("\nStop it later with `afterburner schedule uninstall`."));
1034
+ return true;
1035
+ } catch (error) {
1036
+ if (error?.code === "ABORT_ERR") {
1037
+ console.log(dim("\nSkipped scheduling."));
1038
+ return false;
1039
+ }
1040
+ console.log(
1041
+ errYellow(
1042
+ `Could not install a native schedule: ${error instanceof Error ? error.message : String(error)}. Run \`afterburner schedule install\` later, or use \`afterburner watch\`.`
1043
+ )
1044
+ );
1045
+ return false;
1046
+ } finally {
1047
+ created?.close();
1048
+ }
1049
+ }
1050
+ async function offerDesktopNotifications(deps = {}) {
1051
+ const platform = deps.platform ?? process.platform;
1052
+ const created = deps.rl ? null : createInterface({ input: process.stdin, output: process.stdout });
1053
+ created?.on("SIGINT", () => created.close());
1054
+ const rl = deps.rl ?? created;
1055
+ try {
1056
+ const answer = (await rl.question("\nSend a desktop notification when a run completes? [y/N]: ")).trim().toLowerCase();
1057
+ if (answer !== "y" && answer !== "yes") {
1058
+ console.log(dim("Skipped \u2014 set notify.desktop: true in the config to enable later."));
1059
+ return false;
1060
+ }
1061
+ const has = deps.has ?? commandExists;
1062
+ if (platform === "darwin") {
1063
+ if (!await has("terminal-notifier") && await has("brew")) {
1064
+ const sub = (await rl.question(
1065
+ "Install terminal-notifier for clickable banners (brew install terminal-notifier)? [y/N]: "
1066
+ )).trim().toLowerCase();
1067
+ if (sub === "y" || sub === "yes") {
1068
+ try {
1069
+ console.log(dim("Installing terminal-notifier\u2026"));
1070
+ await (deps.install ?? brewInstall)("terminal-notifier");
1071
+ console.log(` ${green("\u2713")} installed terminal-notifier`);
1072
+ } catch (error) {
1073
+ console.log(
1074
+ errYellow(
1075
+ `Could not install terminal-notifier: ${error instanceof Error ? error.message : String(error)}. Banners will use osascript (no click) until you install it.`
1076
+ )
1077
+ );
1078
+ }
1079
+ }
1080
+ }
1081
+ }
1082
+ const backendReady = platform === "darwin" || platform === "win32" ? true : platform === "linux" ? await has("notify-send") : false;
1083
+ if (backendReady) {
1084
+ console.log(dim("Desktop notifications on \u2014 a banner fires when a run completes."));
1085
+ } else if (platform === "linux") {
1086
+ console.log(
1087
+ errYellow(
1088
+ "Desktop notifications enabled, but notify-send is not on PATH \u2014 install libnotify (Debian/Ubuntu: `apt install libnotify-bin`) for banners to appear."
1089
+ )
1090
+ );
1091
+ } else {
1092
+ console.log(
1093
+ errYellow(
1094
+ `Desktop notifications enabled, but ${platform} has no desktop banner backend; runs still log to the console and run store.`
1095
+ )
1096
+ );
1097
+ }
1098
+ return true;
1099
+ } catch (error) {
1100
+ if (error?.code === "ABORT_ERR") return false;
1101
+ console.log(
1102
+ errYellow(
1103
+ `Could not set up notifications: ${error instanceof Error ? error.message : String(error)}.`
1104
+ )
1105
+ );
1106
+ return false;
1107
+ } finally {
1108
+ created?.close();
1109
+ }
1110
+ }
1111
+ function brewInstall(formula) {
1112
+ return new Promise((resolve7, reject) => {
1113
+ const child = spawn("brew", ["install", formula], { stdio: "inherit" });
1114
+ child.on("error", reject);
1115
+ child.on(
1116
+ "close",
1117
+ (code) => code === 0 ? resolve7() : reject(new Error(`brew install exited with code ${code}`))
1118
+ );
1119
+ });
1120
+ }
885
1121
  async function collectAnswers(rl, opts, cwd = process.cwd()) {
886
1122
  let targetDir = cwd;
887
1123
  let target = join2(targetDir, "afterburner.config.mjs");
@@ -889,14 +1125,14 @@ async function collectAnswers(rl, opts, cwd = process.cwd()) {
889
1125
  "Afterburner turns unused Claude subscription quota into small, reviewed pull requests.\nDry-run first: nothing executes or spends until a live engine is set in the config\nAND you pass --live. Three questions, then an optional health check.\n"
890
1126
  );
891
1127
  console.log(bold("Step 1 of 3 \u2014 Engine (who does the work, and what it can spend)"));
892
- BACKENDS.forEach((name, i) => {
1128
+ OFFERED_BACKENDS.forEach((name, i) => {
893
1129
  console.log(` ${i + 1}. ${name}: ${ENGINE_CHOICES[name]}`);
894
1130
  });
895
- const backend = BACKENDS[await askChoice(rl, "Engine [1]: ", BACKENDS.length)] ?? "dry-run";
1131
+ const backend = OFFERED_BACKENDS[await askChoice(rl, "Engine [1]: ", OFFERED_BACKENDS.length)] ?? "dry-run";
896
1132
  console.log(step("Engine", backend));
897
1133
  console.log(`
898
1134
  ${bold("Step 2 of 3 \u2014 Repository (the allowlist of what it may touch)")}`);
899
- const detectedRepo = existsSync3(join2(cwd, ".git")) ? cwd : null;
1135
+ const detectedRepo = existsSync4(join2(cwd, ".git")) ? cwd : null;
900
1136
  if (detectedRepo) {
901
1137
  console.log(` - press Enter to use this folder (a git repo): ${detectedRepo}`);
902
1138
  }
@@ -965,7 +1201,7 @@ function assertCanWriteConfig(targetDir, target, force) {
965
1201
  }
966
1202
  function configConflict(targetDir, target) {
967
1203
  const existing = CONFIG_FILENAMES.map((name) => join2(targetDir, name)).filter(
968
- (p) => existsSync3(p)
1204
+ (p) => existsSync4(p)
969
1205
  );
970
1206
  return {
971
1207
  exact: existing.includes(target) ? target : null,
@@ -986,7 +1222,7 @@ Overwrite it with these new answers? Only this file changes \u2014 your repo is
986
1222
  return answer === "y" || answer === "yes";
987
1223
  }
988
1224
  function hasConfig(dir) {
989
- return CONFIG_FILENAMES.some((name) => existsSync3(join2(dir, name)));
1225
+ return CONFIG_FILENAMES.some((name) => existsSync4(join2(dir, name)));
990
1226
  }
991
1227
  async function askChoice(rl, prompt, count) {
992
1228
  for (; ; ) {
@@ -999,7 +1235,7 @@ async function askChoice(rl, prompt, count) {
999
1235
  function localRepoPath(repoUrl, base = process.cwd()) {
1000
1236
  if (repoUrl === "" || looksRemoteRepoUrl(repoUrl)) return null;
1001
1237
  const expanded = repoUrl === "~" ? homedir() : repoUrl.replace(/^~(?=\/)/, homedir());
1002
- const absolute = resolve3(base, expanded);
1238
+ const absolute = resolve4(base, expanded);
1003
1239
  return statSync2(absolute, { throwIfNoEntry: false })?.isDirectory() ? absolute : null;
1004
1240
  }
1005
1241
  function renderOnboardingSummary(opts) {
@@ -1025,7 +1261,7 @@ function renderOnboardingSummary(opts) {
1025
1261
  );
1026
1262
  return lines.join("\n");
1027
1263
  }
1028
- function renderConfig(backend, budgetProvider, repoUrl) {
1264
+ function renderConfig(backend, budgetProvider, repoUrl, desktopNotify = false) {
1029
1265
  const repoBlock = repoUrl ? ` {
1030
1266
  url: ${JSON.stringify(repoUrl)},
1031
1267
  defaultBranch: 'main',
@@ -1075,6 +1311,11 @@ ${repoBlock}
1075
1311
  // money at API rates outside promotional windows.
1076
1312
  allowFable: false,
1077
1313
  },
1314
+ notify: {
1315
+ // Desktop banner when a run completes (macOS/Linux/Windows). The opened PR
1316
+ // is still the primary notification; this is a local convenience.
1317
+ desktop: ${desktopNotify},
1318
+ },
1078
1319
  };
1079
1320
 
1080
1321
  export default config;
@@ -1174,7 +1415,7 @@ function registerRunOnce(program2) {
1174
1415
  selector: createSelector(config),
1175
1416
  runner,
1176
1417
  store: new JsonlRunStore(),
1177
- notifier: new ConsoleNotifier()
1418
+ notifier: createNotifier(config)
1178
1419
  });
1179
1420
  if (outcomes.some((o) => o.status === "completed")) await ignite();
1180
1421
  for (const outcome of outcomes) {
@@ -1207,72 +1448,6 @@ ${section(emoji.rocket, "Next")}`);
1207
1448
  });
1208
1449
  }
1209
1450
 
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
1451
  // src/cli/commands/skill.ts
1277
1452
  import { copyFile, mkdir as mkdir3 } from "fs/promises";
1278
1453
  import { existsSync as existsSync5 } from "fs";
@@ -1302,7 +1477,7 @@ function registerSkill(program2) {
1302
1477
  }
1303
1478
 
1304
1479
  // src/cli/commands/statusline.ts
1305
- import { spawn } from "child_process";
1480
+ import { spawn as spawn2 } from "child_process";
1306
1481
  import { existsSync as existsSync6 } from "fs";
1307
1482
  import { copyFile as copyFile2, mkdir as mkdir4, readFile, rm as rm3, writeFile as writeFile4 } from "fs/promises";
1308
1483
  import { dirname as dirname3, join as join4 } from "path";
@@ -1377,7 +1552,7 @@ async function readWrappedState() {
1377
1552
  }
1378
1553
  function passThrough(command, input) {
1379
1554
  return new Promise((resolve7) => {
1380
- const child = spawn(command, { shell: true, stdio: ["pipe", "inherit", "inherit"] });
1555
+ const child = spawn2(command, { shell: true, stdio: ["pipe", "inherit", "inherit"] });
1381
1556
  child.on("error", () => resolve7());
1382
1557
  child.on("close", () => resolve7());
1383
1558
  child.stdin.on("error", () => {
@@ -1697,7 +1872,7 @@ function registerWatch(program2) {
1697
1872
  selector: createSelector(config),
1698
1873
  runner,
1699
1874
  store: new JsonlRunStore(),
1700
- notifier: new ConsoleNotifier()
1875
+ notifier: createNotifier(config)
1701
1876
  });
1702
1877
  for (const outcome of outcomes) {
1703
1878
  console.log(`[afterburner] ${outcome.repoUrl}: ${outcome.status}, ${outcome.reason}`);
@@ -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.13",
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": {