claude-maestro 0.1.20 → 0.1.22

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.
Files changed (28) hide show
  1. package/out/main/index.js +1020 -254
  2. package/out/preload/index.js +12 -1
  3. package/out/renderer/assets/{index-CALj_g-h.js → index-5VwvlSBH.js} +2 -2
  4. package/out/renderer/assets/{index-RU5VUPJx.js → index-8y8_VEXe.js} +2 -2
  5. package/out/renderer/assets/{index-2Q5NJLLa.js → index-B3PqNdfC.js} +2 -2
  6. package/out/renderer/assets/{index-CwrdXZxY.js → index-BAbAYJ5Y.js} +2 -2
  7. package/out/renderer/assets/{index-K7jPnu8A.js → index-BZcI6lAQ.js} +1 -1
  8. package/out/renderer/assets/{index-Dpj9EP42.js → index-B_KGykK5.js} +3 -3
  9. package/out/renderer/assets/{index-BeXyvqqV.js → index-BlF1OgRv.js} +5370 -3153
  10. package/out/renderer/assets/{index-KjJOaoK3.js → index-CCNlPk-t.js} +2 -2
  11. package/out/renderer/assets/{index-NXP0WfIH.js → index-CVHp4WAz.js} +2 -2
  12. package/out/renderer/assets/{index-BSnEdUx8.js → index-CeEj811j.js} +5 -5
  13. package/out/renderer/assets/{index-B4pxYlCv.css → index-D62W3fL9.css} +1065 -102
  14. package/out/renderer/assets/{index-BD5wVgZG.js → index-D8JIi1r1.js} +2 -2
  15. package/out/renderer/assets/{index-DMLpIsZn.js → index-DBWvOvJX.js} +2 -2
  16. package/out/renderer/assets/{index-BKVqTAbd.js → index-DC-oZjAl.js} +3 -3
  17. package/out/renderer/assets/{index-CqEbh7gN.js → index-DCdVxGT4.js} +2 -2
  18. package/out/renderer/assets/{index-BX4eMUiW.js → index-DTVTUfRt.js} +2 -2
  19. package/out/renderer/assets/{index-BdkQGOfF.js → index-DmASR2LM.js} +5 -5
  20. package/out/renderer/assets/{index-BYrBOYyo.js → index-KxHDPFk3.js} +5 -5
  21. package/out/renderer/assets/{index-B9wQ40iJ.js → index-Tn8iVXu1.js} +4 -4
  22. package/out/renderer/assets/{index-BUr9qTcP.js → index-UF6qHegH.js} +5 -5
  23. package/out/renderer/assets/{index-DFnn9t0U.js → index-Xg74Ilp9.js} +5 -5
  24. package/out/renderer/assets/{index-DwCelZNB.js → index-XkfWJ3Sj.js} +5 -5
  25. package/out/renderer/assets/{index-C2xG1-2F.js → index-xFr-MERG.js} +2 -2
  26. package/out/renderer/assets/{index--LsdCcT2.js → index-xKQyIF3m.js} +2 -2
  27. package/out/renderer/index.html +2 -2
  28. package/package.json +2 -1
package/out/main/index.js CHANGED
@@ -463,6 +463,12 @@ async function gitChangedFiles(folder) {
463
463
  }
464
464
  }
465
465
  const MAX_DIFF_CHARS = 5e5;
466
+ function toFileDiff(text) {
467
+ const binary = /^Binary files .* differ$/m.test(text);
468
+ if (text.length <= MAX_DIFF_CHARS) return { diff: text, binary, truncated: false };
469
+ const cut = text.lastIndexOf("\n", MAX_DIFF_CHARS);
470
+ return { diff: text.slice(0, cut > 0 ? cut : MAX_DIFF_CHARS), binary, truncated: true };
471
+ }
466
472
  async function gitFileDiff(folder, path2) {
467
473
  const empty = { diff: "", binary: false, truncated: false };
468
474
  try {
@@ -476,11 +482,47 @@ async function gitFileDiff(folder, path2) {
476
482
  } else {
477
483
  res = await git(root2, ["diff", "--no-index", "--", "/dev/null", path2]);
478
484
  }
485
+ return toFileDiff(res.stdout);
486
+ } catch {
487
+ return empty;
488
+ }
489
+ }
490
+ function parseNameStatus(out) {
491
+ const files = [];
492
+ for (const line of out.split(/\r?\n/)) {
493
+ if (!line.trim()) continue;
494
+ const parts = line.split(" ");
495
+ const letter = parts[0]?.[0] ?? "M";
496
+ if ((letter === "R" || letter === "C") && parts.length >= 3) {
497
+ files.push({ path: parts[2], status: letter, origPath: parts[1] });
498
+ } else if (parts[1]) {
499
+ files.push({ path: parts[1], status: letter });
500
+ }
501
+ }
502
+ files.sort((a, b) => a.path.localeCompare(b.path));
503
+ return files;
504
+ }
505
+ async function branchDiff(folder, branch, baseBranch) {
506
+ const empty = { diff: "", truncated: false, files: [], branch, baseBranch };
507
+ try {
508
+ const mb = await git(folder, ["merge-base", baseBranch, branch]);
509
+ const base = mb.code === 0 ? mb.stdout.trim() : "";
510
+ if (!base) return empty;
511
+ const ns = await git(folder, ["diff", "--name-status", base, branch]);
512
+ const files = ns.code === 0 ? parseNameStatus(ns.stdout) : [];
513
+ const res = await git(folder, ["diff", base, branch]);
479
514
  const text = res.stdout;
480
- const binary = /^Binary files .* differ$/m.test(text);
481
- if (text.length <= MAX_DIFF_CHARS) return { diff: text, binary, truncated: false };
515
+ if (text.length <= MAX_DIFF_CHARS) {
516
+ return { diff: text, truncated: false, files, branch, baseBranch };
517
+ }
482
518
  const cut = text.lastIndexOf("\n", MAX_DIFF_CHARS);
483
- return { diff: text.slice(0, cut > 0 ? cut : MAX_DIFF_CHARS), binary, truncated: true };
519
+ return {
520
+ diff: text.slice(0, cut > 0 ? cut : MAX_DIFF_CHARS),
521
+ truncated: true,
522
+ files,
523
+ branch,
524
+ baseBranch
525
+ };
484
526
  } catch {
485
527
  return empty;
486
528
  }
@@ -579,7 +621,7 @@ async function startMergeLeaveConflicts(baseFolder, branch, baseBranch) {
579
621
  const merging = await git(baseFolder, ["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
580
622
  return { ok: false, conflict: merging.code === 0, output: res.output };
581
623
  }
582
- async function pushBranch(folder, branch) {
624
+ async function pushBranch(folder, branch, env) {
583
625
  const up = await git(folder, [
584
626
  "rev-parse",
585
627
  "--abbrev-ref",
@@ -589,15 +631,20 @@ async function pushBranch(folder, branch) {
589
631
  if (up.code !== 0) return null;
590
632
  const remote = up.stdout.trim().split("/")[0];
591
633
  if (!remote) return null;
592
- const res = await git(folder, ["push", remote, branch]);
634
+ const res = await git(folder, ["push", remote, branch], env);
593
635
  return { ok: res.code === 0, output: res.output };
594
636
  }
595
- function gh(cwd, args) {
637
+ function gh$1(cwd, args, env) {
596
638
  return new Promise((resolve2, reject) => {
597
639
  child_process.execFile(
598
640
  "gh",
599
641
  args,
600
- { cwd, windowsHide: true, maxBuffer: 8 * 1024 * 1024 },
642
+ {
643
+ cwd,
644
+ windowsHide: true,
645
+ maxBuffer: 8 * 1024 * 1024,
646
+ ...env ? { env: { ...process.env, ...env } } : {}
647
+ },
601
648
  (err, stdout, stderr) => {
602
649
  if (err && err.code === "ENOENT") {
603
650
  reject(
@@ -621,7 +668,7 @@ function extractPrUrl(text) {
621
668
  const m = text.match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
622
669
  return m ? m[0] : "";
623
670
  }
624
- async function createPullRequest(folder, branch, baseBranch, title, body) {
671
+ async function createPullRequest(folder, branch, baseBranch, title, body, env) {
625
672
  const remote = await defaultRemote(folder);
626
673
  if (!remote) {
627
674
  return {
@@ -629,28 +676,21 @@ async function createPullRequest(folder, branch, baseBranch, title, body) {
629
676
  output: "This repository has no git remote, so there is nowhere to open a pull request. Add a GitHub remote (e.g. `git remote add origin <url>`) first."
630
677
  };
631
678
  }
632
- const push = await git(folder, ["push", "-u", remote, branch]);
679
+ const push = await git(folder, ["push", "-u", remote, branch], env);
633
680
  if (push.code !== 0) {
634
681
  return { ok: false, output: `Pushing "${branch}" to ${remote} failed:
635
682
  ${push.output}` };
636
683
  }
637
- const res = await gh(folder, [
638
- "pr",
639
- "create",
640
- "--base",
641
- baseBranch,
642
- "--head",
643
- branch,
644
- "--title",
645
- title,
646
- "--body",
647
- body
648
- ]);
684
+ const res = await gh$1(
685
+ folder,
686
+ ["pr", "create", "--base", baseBranch, "--head", branch, "--title", title, "--body", body],
687
+ env
688
+ );
649
689
  if (res.code === 0) {
650
690
  return { ok: true, url: extractPrUrl(res.stdout) || lastLine(res.stdout), output: res.output };
651
691
  }
652
692
  if (/already exists/i.test(res.output)) {
653
- const view = await gh(folder, ["pr", "view", branch, "--json", "url", "--jq", ".url"]);
693
+ const view = await gh$1(folder, ["pr", "view", branch, "--json", "url", "--jq", ".url"], env);
654
694
  const url = view.code === 0 ? extractPrUrl(view.stdout) || lastLine(view.stdout) : "";
655
695
  return {
656
696
  ok: true,
@@ -670,10 +710,18 @@ async function defaultRemote(folder) {
670
710
  if (remotes.length === 0) return null;
671
711
  return remotes.includes("origin") ? "origin" : remotes[0];
672
712
  }
673
- async function publishBranch(folder, branch) {
713
+ async function remoteUrl(folder) {
714
+ const remote = await defaultRemote(folder);
715
+ if (!remote) return null;
716
+ const res = await git(folder, ["remote", "get-url", remote]);
717
+ if (res.code !== 0) return null;
718
+ const url = res.stdout.trim();
719
+ return url || null;
720
+ }
721
+ async function publishBranch(folder, branch, env) {
674
722
  const remote = await defaultRemote(folder);
675
723
  if (!remote) return null;
676
- const res = await git(folder, ["push", "-u", remote, branch]);
724
+ const res = await git(folder, ["push", "-u", remote, branch], env);
677
725
  return { ok: res.code === 0, output: res.output };
678
726
  }
679
727
  async function dirtyCount(folder) {
@@ -709,6 +757,39 @@ async function branchExists(repoRoot, branch) {
709
757
  const res = await git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
710
758
  return res.code === 0;
711
759
  }
760
+ async function isValidBranchName(name) {
761
+ const n = name.trim();
762
+ if (!n) return false;
763
+ const res = await git(process.cwd(), ["check-ref-format", "--branch", n]);
764
+ return res.code === 0;
765
+ }
766
+ async function checkoutBranch(folder, branch) {
767
+ return git(folder, ["checkout", branch]);
768
+ }
769
+ async function createBranch(folder, branch, from) {
770
+ const args = ["checkout", "-b", branch, ...[]];
771
+ return git(folder, args);
772
+ }
773
+ async function worktreeBranches(repoRoot) {
774
+ const res = await git(repoRoot, ["worktree", "list", "--porcelain"]);
775
+ if (res.code !== 0) return [];
776
+ const out = [];
777
+ let cur = null;
778
+ for (const line of res.stdout.split(/\r?\n/)) {
779
+ if (line.startsWith("worktree ")) {
780
+ if (cur) out.push(cur);
781
+ cur = { path: line.slice("worktree ".length).trim().replace(/\\/g, "/"), branch: null };
782
+ } else if (line.startsWith("branch ") && cur) {
783
+ cur.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
784
+ }
785
+ }
786
+ if (cur) out.push(cur);
787
+ return out;
788
+ }
789
+ async function branchCheckedOutAt(repoRoot, branch) {
790
+ const wts = await worktreeBranches(repoRoot);
791
+ return wts.find((w) => w.branch === branch)?.path ?? null;
792
+ }
712
793
  async function listWorktreePaths(repoRoot) {
713
794
  const res = await git(repoRoot, ["worktree", "list", "--porcelain"]);
714
795
  if (res.code !== 0) return [];
@@ -859,6 +940,33 @@ async function listCheckpoints(folder) {
859
940
  }
860
941
  return out;
861
942
  }
943
+ async function checkpointDiff(folder, id) {
944
+ const empty = { diff: "", binary: false, truncated: false };
945
+ if (!isValidCheckpointId(id)) return empty;
946
+ const root2 = await repoRootOf(folder);
947
+ const ref = CHECKPOINT_REF_PREFIX + id;
948
+ const resolved = await git(root2, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
949
+ if (resolved.code !== 0 || !resolved.stdout.trim()) return empty;
950
+ const hash = resolved.stdout.trim();
951
+ const indexFile = path.join(os.tmpdir(), `maestro-ckpt-diff-${id}-${checkpointSeq++}.index`);
952
+ try {
953
+ const indexEnv = { GIT_INDEX_FILE: indexFile };
954
+ const add2 = await git(root2, ["add", "-A"], indexEnv);
955
+ if (add2.code !== 0) return empty;
956
+ const writeTree = await git(root2, ["write-tree"], indexEnv);
957
+ if (writeTree.code !== 0) return empty;
958
+ const curTree = writeTree.stdout.trim();
959
+ const res = await git(root2, ["diff", hash, curTree]);
960
+ return toFileDiff(res.stdout);
961
+ } catch {
962
+ return empty;
963
+ } finally {
964
+ try {
965
+ fs.rmSync(indexFile, { force: true });
966
+ } catch {
967
+ }
968
+ }
969
+ }
862
970
  async function restoreCheckpoint(folder, id) {
863
971
  if (!isValidCheckpointId(id)) {
864
972
  return { ok: false, output: `Invalid checkpoint id: ${id}`, safety: null };
@@ -906,6 +1014,68 @@ function excludeFilePathSync(folder) {
906
1014
  return null;
907
1015
  }
908
1016
  }
1017
+ function gh(args) {
1018
+ return new Promise((resolve) => {
1019
+ child_process.execFile("gh", args, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
1020
+ const code = err ? err.code ?? 1 : 0;
1021
+ resolve({ code, stdout, stderr });
1022
+ });
1023
+ });
1024
+ }
1025
+ function stripAnsi$1(text) {
1026
+ const esc = String.fromCharCode(27);
1027
+ return text.replace(new RegExp(esc + "\\[[0-9;]*m", "g"), "");
1028
+ }
1029
+ async function listAccounts() {
1030
+ const res = await gh(["auth", "status"]);
1031
+ const text = stripAnsi$1(`${res.stdout}
1032
+ ${res.stderr}`);
1033
+ const accounts = [];
1034
+ let last = null;
1035
+ for (const line of text.split(/\r?\n/)) {
1036
+ const logged = line.match(/Logged in to (\S+) account (\S+)/);
1037
+ if (logged) {
1038
+ last = { host: logged[1], login: logged[2] };
1039
+ accounts.push(last);
1040
+ continue;
1041
+ }
1042
+ const activeMatch = line.match(/Active account:\s*(true|false)/i);
1043
+ if (activeMatch && last) {
1044
+ last.active = activeMatch[1].toLowerCase() === "true";
1045
+ }
1046
+ }
1047
+ return accounts;
1048
+ }
1049
+ async function tokenFor(host, login) {
1050
+ const res = await gh(["auth", "token", "--hostname", host, "--user", login]);
1051
+ if (res.code !== 0) return null;
1052
+ const token = res.stdout.trim();
1053
+ return token || null;
1054
+ }
1055
+ async function repoSlug(folder) {
1056
+ const url = await remoteUrl(folder);
1057
+ if (!url) return null;
1058
+ let rest = url.trim();
1059
+ const scp = rest.match(/^[^@]+@([^:]+):(.+)$/);
1060
+ if (scp) {
1061
+ rest = `${scp[1]}/${scp[2]}`;
1062
+ } else {
1063
+ rest = rest.replace(/^[a-z]+:\/\//i, "").replace(/^[^@/]+@/, "");
1064
+ }
1065
+ rest = rest.replace(/\.git$/i, "").replace(/\/+$/, "");
1066
+ if (!rest.includes("/")) return null;
1067
+ return rest.toLowerCase();
1068
+ }
1069
+ async function accountEnvForRepo(folder, mapping) {
1070
+ if (!mapping) return void 0;
1071
+ const slug = await repoSlug(folder);
1072
+ if (!slug) return void 0;
1073
+ const entry = mapping[slug];
1074
+ if (!entry) return void 0;
1075
+ const token = await tokenFor(entry.host, entry.login);
1076
+ if (!token) return void 0;
1077
+ return { GH_TOKEN: token };
1078
+ }
909
1079
  const BEL = "\x07";
910
1080
  const CSI_RE = new RegExp(
911
1081
  "[\\x1b\\x9b][\\[\\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]",
@@ -939,6 +1109,10 @@ class StatusDetector {
939
1109
  get lastOutput() {
940
1110
  return this.lastOutputAt;
941
1111
  }
1112
+ /** ANSI-stripped tail of recent output, for prompt-content checks. */
1113
+ get recentText() {
1114
+ return stripAnsi(this.tail);
1115
+ }
942
1116
  /** ms epoch when the current status was continuously entered (watchdog clock). */
943
1117
  get since() {
944
1118
  return this.statusSince;
@@ -1377,7 +1551,11 @@ class AutoExpandService {
1377
1551
  if (!info.isRepo || !info.repoRoot) return;
1378
1552
  await ensureBranch(info.repoRoot, cfg.branch);
1379
1553
  try {
1380
- await publishBranch(info.repoRoot, cfg.branch);
1554
+ const env = await accountEnvForRepo(
1555
+ info.repoRoot,
1556
+ this.persistence.state.settings.githubRepoAccounts
1557
+ );
1558
+ await publishBranch(info.repoRoot, cfg.branch, env);
1381
1559
  } catch {
1382
1560
  }
1383
1561
  }
@@ -1457,7 +1635,11 @@ class AutoExpandService {
1457
1635
  }
1458
1636
  await ensureBranch(info.repoRoot, cfg.branch);
1459
1637
  try {
1460
- await publishBranch(info.repoRoot, cfg.branch);
1638
+ const env = await accountEnvForRepo(
1639
+ info.repoRoot,
1640
+ this.persistence.state.settings.githubRepoAccounts
1641
+ );
1642
+ await publishBranch(info.repoRoot, cfg.branch, env);
1461
1643
  } catch {
1462
1644
  }
1463
1645
  const existingTitles = this.features.list(session.id).map((f) => f.title);
@@ -2497,7 +2679,7 @@ const MIN_CONFIDENCE = 0.6;
2497
2679
  const MAX_SUGGESTIONS_PER_DETECT = 2;
2498
2680
  const JUDGE_DAILY_CAP = 12;
2499
2681
  const MAX_SUGGESTIONS = 60;
2500
- function localDay$1() {
2682
+ function localDay$2() {
2501
2683
  const d = /* @__PURE__ */ new Date();
2502
2684
  return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
2503
2685
  }
@@ -2679,7 +2861,7 @@ class FactoryService {
2679
2861
  return;
2680
2862
  }
2681
2863
  const turnsAtStart = g.turnsSinceDetect;
2682
- const day = localDay$1();
2864
+ const day = localDay$2();
2683
2865
  const callsToday = g.judgeDay === day ? g.judgeCallsToday : 0;
2684
2866
  if (callsToday >= JUDGE_DAILY_CAP) return;
2685
2867
  const recent = messages.filter((m) => !m.pending && m.text?.trim()).slice(-6);
@@ -2705,7 +2887,7 @@ class FactoryService {
2705
2887
  this.inFlight = null;
2706
2888
  this.setBusy(false);
2707
2889
  const g2 = this.store.loadGrowth();
2708
- const day2 = localDay$1();
2890
+ const day2 = localDay$2();
2709
2891
  this.store.setGrowth({
2710
2892
  ...g2,
2711
2893
  lastDetectAt: Date.now(),
@@ -3858,7 +4040,10 @@ class FsService {
3858
4040
  }
3859
4041
  }
3860
4042
  }
4043
+ const TOKENS_PER_XP = 1e3;
3861
4044
  const XP_TABLE = {
4045
+ "tokens.burn": 0,
4046
+ // computed from meta.tokens in xpForEvent
3862
4047
  "session.turn": 5,
3863
4048
  "conductor.turn": 5,
3864
4049
  "action.run": 8,
@@ -3875,6 +4060,9 @@ const XP_TABLE = {
3875
4060
  "feature.merge": 80
3876
4061
  };
3877
4062
  function xpForEvent(e) {
4063
+ if (e.type === "tokens.burn") {
4064
+ return Math.max(0, Math.floor((e.meta?.tokens ?? 0) / TOKENS_PER_XP));
4065
+ }
3878
4066
  let xp = XP_TABLE[e.type] ?? 0;
3879
4067
  if (e.type === "worktree.merge") xp += Math.min(e.meta?.commits ?? 0, 10) * 3;
3880
4068
  return xp;
@@ -3903,6 +4091,8 @@ const DEFAULT_GAME_STATE = {
3903
4091
  counters: {},
3904
4092
  nightTurns: 0,
3905
4093
  earlyTurns: 0,
4094
+ tokensBurned: 0,
4095
+ usageTokensSeen: -1,
3906
4096
  createdAt: 0
3907
4097
  };
3908
4098
  const c = (ctx, t) => ctx.counters[t] ?? 0;
@@ -3930,6 +4120,23 @@ const ACHIEVEMENTS = [
3930
4120
  { id: "first-agent", title: "Agent Architect", desc: "Create your first agent.", icon: "🤖", category: "factory", predicate: (x) => c(x, "factory.agent") >= 1 },
3931
4121
  { id: "toolsmith", title: "Toolsmith", desc: "Create 5 skills/agents.", icon: "⚒", category: "factory", predicate: (x) => c(x, "factory.skill") + c(x, "factory.agent") >= 5 },
3932
4122
  { id: "master-toolsmith", title: "Master Toolsmith", desc: "Create 20 skills/agents.", icon: "🏭", category: "factory", predicate: (x) => c(x, "factory.skill") + c(x, "factory.agent") >= 20 },
4123
+ // workflow (checkpoints, actions, feature specs, auto-expand)
4124
+ { id: "first-checkpoint", title: "Safe Keeper", desc: "Make your first checkpoint.", icon: "📍", category: "workflow", predicate: (x) => c(x, "checkpoint.create") >= 1 },
4125
+ { id: "checkpoint-25", title: "Time Traveler", desc: "Make 25 checkpoints.", icon: "⏳", category: "workflow", predicate: (x) => c(x, "checkpoint.create") >= 25 },
4126
+ { id: "first-action", title: "Press Start", desc: "Run your first reusable action.", icon: "▶️", category: "workflow", predicate: (x) => c(x, "action.run") >= 1 },
4127
+ { id: "action-50", title: "Power User", desc: "Run 50 actions.", icon: "⚡", category: "workflow", predicate: (x) => c(x, "action.run") >= 50 },
4128
+ { id: "first-feature-spec", title: "Drawing Board", desc: "Save your first feature spec.", icon: "📝", category: "workflow", predicate: (x) => c(x, "feature.save") >= 1 },
4129
+ { id: "feature-architect", title: "Spec Architect", desc: "Save 10 feature specs.", icon: "📐", category: "workflow", predicate: (x) => c(x, "feature.save") >= 10 },
4130
+ { id: "first-autoexpand", title: "Brainstormer", desc: "Finish your first Auto-Expand run.", icon: "💡", category: "workflow", predicate: (x) => c(x, "autoexpand.done") >= 1 },
4131
+ { id: "autoexpand-10", title: "Idea Machine", desc: "Finish 10 Auto-Expand runs.", icon: "🧠", category: "workflow", predicate: (x) => c(x, "autoexpand.done") >= 10 },
4132
+ // guardian (Sentinels)
4133
+ { id: "first-sentinel", title: "On Watch", desc: "Run your first Sentinel check.", icon: "🛡", category: "guardian", predicate: (x) => c(x, "sentinel.run") >= 1 },
4134
+ { id: "sentinel-25", title: "Vigilant", desc: "Run 25 Sentinel checks.", icon: "👁", category: "guardian", predicate: (x) => c(x, "sentinel.run") >= 25 },
4135
+ { id: "sentinel-100", title: "Guardian", desc: "Run 100 Sentinel checks.", icon: "🦾", category: "guardian", predicate: (x) => c(x, "sentinel.run") >= 100 },
4136
+ // tokens (burning tokens levels you up)
4137
+ { id: "tokens-1m", title: "Token Tinkerer", desc: "Burn 1M tokens.", icon: "🪙", category: "tokens", predicate: (x) => x.tokensBurned >= 1e6 },
4138
+ { id: "tokens-10m", title: "Token Furnace", desc: "Burn 10M tokens.", icon: "🔥", category: "tokens", predicate: (x) => x.tokensBurned >= 1e7 },
4139
+ { id: "tokens-100m", title: "Token Inferno", desc: "Burn 100M tokens.", icon: "🌋", category: "tokens", predicate: (x) => x.tokensBurned >= 1e8 },
3933
4140
  // streak
3934
4141
  { id: "streak-3", title: "Warmed Up", desc: "Keep a 3-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 3 },
3935
4142
  { id: "streak-7", title: "On Fire", desc: "Keep a 7-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 7 },
@@ -3939,7 +4146,11 @@ const ACHIEVEMENTS = [
3939
4146
  { id: "early-bird", title: "Early Bird", desc: "Finish a turn between 5 and 8am.", icon: "🌅", category: "time", predicate: (x) => x.earlyTurns >= 1 },
3940
4147
  // level
3941
4148
  { id: "level-10", title: "Double Digits", desc: "Reach level 10.", icon: "⭐", category: "level", predicate: (x) => x.level >= 10 },
3942
- { id: "level-25", title: "Maestro Prime", desc: "Reach level 25.", icon: "🌟", category: "level", predicate: (x) => x.level >= 25 }
4149
+ { id: "level-25", title: "Maestro Prime", desc: "Reach level 25.", icon: "🌟", category: "level", predicate: (x) => x.level >= 25 },
4150
+ { id: "level-50", title: "Maestro Legend", desc: "Reach level 50.", icon: "👑", category: "level", predicate: (x) => x.level >= 50 },
4151
+ // mastery (cross-cutting capstones)
4152
+ { id: "polymath", title: "Polymath", desc: "Use sessions, parallel tasks, the Factory, feature specs and Sentinels.", icon: "🧩", category: "mastery", predicate: (x) => c(x, "session.create") >= 1 && c(x, "worktree.create") >= 1 && c(x, "factory.skill") + c(x, "factory.agent") >= 1 && c(x, "feature.save") >= 1 && c(x, "sentinel.run") >= 1 },
4153
+ { id: "all-rounder", title: "All-Rounder", desc: "Merge a worktree, ship a feature and open a PR.", icon: "🏅", category: "mastery", predicate: (x) => c(x, "worktree.merge") >= 1 && c(x, "feature.merge") >= 1 && c(x, "worktree.pr") >= 1 }
3943
4154
  ];
3944
4155
  const DAILY_QUEST_POOL = [
3945
4156
  { id: "q-turns-5", title: "Finish 5 Claude turns", target: 5, reward: 30, events: ["session.turn"] },
@@ -3951,7 +4162,11 @@ const DAILY_QUEST_POOL = [
3951
4162
  { id: "q-conductor-3", title: "Have 3 Conductor turns", target: 3, reward: 30, events: ["conductor.turn"] },
3952
4163
  { id: "q-factory-1", title: "Create a skill or agent", target: 1, reward: 75, events: ["factory.skill", "factory.agent"] },
3953
4164
  { id: "q-feature-1", title: "Save a feature spec", target: 1, reward: 30, events: ["feature.save"] },
3954
- { id: "q-pr-1", title: "Open a pull request", target: 1, reward: 50, events: ["worktree.pr"] }
4165
+ { id: "q-pr-1", title: "Open a pull request", target: 1, reward: 50, events: ["worktree.pr"] },
4166
+ { id: "q-action-3", title: "Run 3 reusable actions", target: 3, reward: 30, events: ["action.run"] },
4167
+ { id: "q-sentinel-1", title: "Run a Sentinel check", target: 1, reward: 25, events: ["sentinel.run"] },
4168
+ { id: "q-autoexpand-1", title: "Finish an Auto-Expand run", target: 1, reward: 40, events: ["autoexpand.done"] },
4169
+ { id: "q-checkpoint-3", title: "Make 3 checkpoints", target: 3, reward: 40, events: ["checkpoint.create"] }
3955
4170
  ];
3956
4171
  const questDef = (id) => DAILY_QUEST_POOL.find((q) => q.id === id);
3957
4172
  function hashStr(s) {
@@ -4017,6 +4232,8 @@ class GamificationStore {
4017
4232
  }
4018
4233
  s.nightTurns = num(s.nightTurns, 0);
4019
4234
  s.earlyTurns = num(s.earlyTurns, 0);
4235
+ s.tokensBurned = num(s.tokensBurned, 0);
4236
+ s.usageTokensSeen = num(s.usageTokensSeen, -1);
4020
4237
  s.xp = num(s.xp, 0);
4021
4238
  if (!s.createdAt) s.createdAt = Date.now();
4022
4239
  this.state = s;
@@ -4053,20 +4270,66 @@ class GamificationStore {
4053
4270
  }
4054
4271
  }
4055
4272
  const ACHIEVEMENT_XP = 25;
4273
+ const TOKEN_POLL_MS = 6e4;
4056
4274
  class GamificationService {
4057
- constructor(getWin2) {
4275
+ /**
4276
+ * @param getUsageTokens returns the lifetime input+output token total (from
4277
+ * UsageService). Polled to award XP for tokens burned since the last check.
4278
+ */
4279
+ constructor(getWin2, getUsageTokens) {
4058
4280
  this.getWin = getWin2;
4281
+ this.getUsageTokens = getUsageTokens;
4059
4282
  this.state = this.store.load();
4060
4283
  }
4061
4284
  store = new GamificationStore();
4062
4285
  state;
4286
+ tokenTimer = null;
4287
+ /** Begin polling burned tokens (baselined on the first tick, so historical
4288
+ * usage is never retroactively dumped into XP). */
4289
+ start() {
4290
+ if (this.tokenTimer || !this.getUsageTokens) return;
4291
+ this.pollTokenBurn();
4292
+ this.tokenTimer = setInterval(() => this.pollTokenBurn(), TOKEN_POLL_MS);
4293
+ }
4063
4294
  /** GameState + derived level fields (for the initial renderer fetch). */
4064
4295
  snapshot() {
4065
4296
  return { ...this.state, ...levelInfo(this.state.xp) };
4066
4297
  }
4067
4298
  dispose() {
4299
+ if (this.tokenTimer) {
4300
+ clearInterval(this.tokenTimer);
4301
+ this.tokenTimer = null;
4302
+ }
4068
4303
  this.store.saveNow();
4069
4304
  }
4305
+ /**
4306
+ * Reconcile burned tokens into XP. The first observation only records the
4307
+ * baseline (no award). Afterward, each whole `TOKENS_PER_XP` of new input+
4308
+ * output tokens grants 1 XP via a `tokens.burn` event; the sub-unit remainder
4309
+ * is carried (the baseline advances only by tokens actually converted), so
4310
+ * even light usage eventually counts. A drop in the total (pruned transcripts)
4311
+ * silently re-baselines.
4312
+ */
4313
+ pollTokenBurn() {
4314
+ if (!this.getUsageTokens) return;
4315
+ try {
4316
+ const total = this.getUsageTokens();
4317
+ if (!Number.isFinite(total) || total < 0) return;
4318
+ const seen = this.state.usageTokensSeen;
4319
+ if (seen < 0 || total < seen) {
4320
+ this.state.usageTokensSeen = total;
4321
+ this.store.set(this.state);
4322
+ return;
4323
+ }
4324
+ const units = Math.floor((total - seen) / TOKENS_PER_XP);
4325
+ if (units <= 0) return;
4326
+ const consumed = units * TOKENS_PER_XP;
4327
+ this.state.usageTokensSeen = seen + consumed;
4328
+ this.award({ type: "tokens.burn", meta: { tokens: consumed } });
4329
+ } catch (err) {
4330
+ console.error("Token-burn poll failed (ignored):", err);
4331
+ }
4332
+ }
4070
4333
  /**
4071
4334
  * Apply one event: bump counters, roll the day (streak + quests), add XP,
4072
4335
  * advance quests, unlock achievements — each guarded so nothing double-counts.
@@ -4092,7 +4355,11 @@ class GamificationService {
4092
4355
  });
4093
4356
  }
4094
4357
  }
4095
- s.counters[e.type] = (s.counters[e.type] ?? 0) + 1;
4358
+ if (e.type === "tokens.burn") {
4359
+ s.tokensBurned += Math.max(0, e.meta?.tokens ?? 0);
4360
+ } else {
4361
+ s.counters[e.type] = (s.counters[e.type] ?? 0) + 1;
4362
+ }
4096
4363
  if (e.type === "session.turn" || e.type === "conductor.turn") {
4097
4364
  const hour = e.meta?.hour ?? now.getHours();
4098
4365
  if (hour >= 0 && hour < 5) s.nightTurns += 1;
@@ -4125,7 +4392,8 @@ class GamificationService {
4125
4392
  nightTurns: s.nightTurns,
4126
4393
  earlyTurns: s.earlyTurns,
4127
4394
  streakLongest: s.streak.longest,
4128
- level: levelForXp(s.xp)
4395
+ level: levelForXp(s.xp),
4396
+ tokensBurned: s.tokensBurned
4129
4397
  };
4130
4398
  for (const a of ACHIEVEMENTS) {
4131
4399
  if (s.achievements[a.id]) continue;
@@ -4281,6 +4549,182 @@ async function clearBackgroundImage() {
4281
4549
  } catch {
4282
4550
  }
4283
4551
  }
4552
+ const PROJECTS_DIR$1 = path.join(os.homedir(), ".claude", "projects");
4553
+ const PREVIEW_MAX_CHARS = 160;
4554
+ function encodeFolder(folder) {
4555
+ return folder.replace(/[^a-zA-Z0-9]/g, "-");
4556
+ }
4557
+ function messageText(message) {
4558
+ if (!message || typeof message !== "object") return null;
4559
+ const content = message.content;
4560
+ if (typeof content === "string") return content.trim() || null;
4561
+ if (Array.isArray(content)) {
4562
+ const parts = [];
4563
+ for (const block of content) {
4564
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
4565
+ parts.push(block.text);
4566
+ }
4567
+ }
4568
+ const joined = parts.join(" ").trim();
4569
+ return joined || null;
4570
+ }
4571
+ return null;
4572
+ }
4573
+ function toPreview(text) {
4574
+ const oneLine = text.replace(/\s+/g, " ").trim();
4575
+ return oneLine.length > PREVIEW_MAX_CHARS ? oneLine.slice(0, PREVIEW_MAX_CHARS) + "…" : oneLine;
4576
+ }
4577
+ function summarizeFile(path2, id) {
4578
+ let raw;
4579
+ try {
4580
+ raw = fs.readFileSync(path2, "utf8");
4581
+ } catch {
4582
+ return null;
4583
+ }
4584
+ let messageCount = 0;
4585
+ let lastActivityAt = 0;
4586
+ let preview = "";
4587
+ for (const line of raw.split("\n")) {
4588
+ if (!line.trim()) continue;
4589
+ let obj;
4590
+ try {
4591
+ obj = JSON.parse(line);
4592
+ } catch {
4593
+ continue;
4594
+ }
4595
+ const type = obj.type;
4596
+ if (type !== "user" && type !== "assistant") continue;
4597
+ messageCount++;
4598
+ const at = Date.parse(typeof obj.timestamp === "string" ? obj.timestamp : "");
4599
+ if (Number.isFinite(at) && at > lastActivityAt) lastActivityAt = at;
4600
+ if (!preview && type === "user" && obj.isMeta !== true) {
4601
+ const text = messageText(obj.message);
4602
+ if (text) preview = toPreview(text);
4603
+ }
4604
+ }
4605
+ return { id, lastActivityAt, messageCount, preview };
4606
+ }
4607
+ function listConversations(folder) {
4608
+ const dir = path.join(PROJECTS_DIR$1, encodeFolder(folder));
4609
+ if (!fs.existsSync(dir)) return [];
4610
+ let files;
4611
+ try {
4612
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
4613
+ } catch {
4614
+ return [];
4615
+ }
4616
+ const out = [];
4617
+ for (const file of files) {
4618
+ const summary = summarizeFile(path.join(dir, file), file.slice(0, -".jsonl".length));
4619
+ if (summary) out.push(summary);
4620
+ }
4621
+ out.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
4622
+ return out;
4623
+ }
4624
+ const SEARCH_MIN_QUERY = 2;
4625
+ const SEARCH_MAX_PER_CONVERSATION = 50;
4626
+ const SEARCH_MAX_RESULTS = 100;
4627
+ const SNIPPET_BEFORE = 40;
4628
+ const SNIPPET_AFTER = 140;
4629
+ const searchCache = /* @__PURE__ */ new Map();
4630
+ function makeSnippet(text, matchIndex, queryLength) {
4631
+ const start = Math.max(0, matchIndex - SNIPPET_BEFORE);
4632
+ const end = Math.min(text.length, matchIndex + queryLength + SNIPPET_AFTER);
4633
+ const core = text.slice(start, end).replace(/\s+/g, " ").trim();
4634
+ return (start > 0 ? "… " : "") + core + (end < text.length ? " …" : "");
4635
+ }
4636
+ function parseForSearch(path2, id) {
4637
+ const conv = { id, cwd: "", lastActivityAt: 0, messages: [] };
4638
+ let raw;
4639
+ try {
4640
+ raw = fs.readFileSync(path2, "utf8");
4641
+ } catch {
4642
+ return conv;
4643
+ }
4644
+ for (const line of raw.split("\n")) {
4645
+ if (!line.includes('"user"') && !line.includes('"assistant"')) continue;
4646
+ let obj;
4647
+ try {
4648
+ obj = JSON.parse(line);
4649
+ } catch {
4650
+ continue;
4651
+ }
4652
+ if (obj.type !== "user" && obj.type !== "assistant") continue;
4653
+ const text = messageText(obj.message);
4654
+ if (text) conv.messages.push(text);
4655
+ if (typeof obj.cwd === "string" && obj.cwd) conv.cwd = obj.cwd;
4656
+ const at = Date.parse(typeof obj.timestamp === "string" ? obj.timestamp : "");
4657
+ if (Number.isFinite(at) && at > conv.lastActivityAt) conv.lastActivityAt = at;
4658
+ }
4659
+ return conv;
4660
+ }
4661
+ function searchableFor(path2, id) {
4662
+ let stat;
4663
+ try {
4664
+ stat = fs.statSync(path2);
4665
+ } catch {
4666
+ searchCache.delete(path2);
4667
+ return { id, cwd: "", lastActivityAt: 0, messages: [] };
4668
+ }
4669
+ const cached = searchCache.get(path2);
4670
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.conv;
4671
+ const conv = parseForSearch(path2, id);
4672
+ searchCache.set(path2, { mtimeMs: stat.mtimeMs, size: stat.size, conv });
4673
+ return conv;
4674
+ }
4675
+ function searchConversations(query) {
4676
+ const q = query.trim();
4677
+ if (q.length < SEARCH_MIN_QUERY) return [];
4678
+ const needle = q.toLowerCase();
4679
+ let projectDirs = [];
4680
+ try {
4681
+ if (fs.existsSync(PROJECTS_DIR$1)) projectDirs = fs.readdirSync(PROJECTS_DIR$1);
4682
+ } catch {
4683
+ return [];
4684
+ }
4685
+ const hits = [];
4686
+ const liveFiles = /* @__PURE__ */ new Set();
4687
+ for (const dir of projectDirs) {
4688
+ const dirPath = path.join(PROJECTS_DIR$1, dir);
4689
+ let files;
4690
+ try {
4691
+ files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".jsonl"));
4692
+ } catch {
4693
+ continue;
4694
+ }
4695
+ for (const file of files) {
4696
+ const path$1 = path.join(dirPath, file);
4697
+ liveFiles.add(path$1);
4698
+ const conv = searchableFor(path$1, file.slice(0, -".jsonl".length));
4699
+ let matchCount = 0;
4700
+ let snippet = null;
4701
+ for (const text of conv.messages) {
4702
+ const lower = text.toLowerCase();
4703
+ let idx = lower.indexOf(needle);
4704
+ while (idx !== -1) {
4705
+ if (!snippet) snippet = makeSnippet(text, idx, q.length);
4706
+ if (++matchCount >= SEARCH_MAX_PER_CONVERSATION) break;
4707
+ idx = lower.indexOf(needle, idx + needle.length);
4708
+ }
4709
+ if (matchCount >= SEARCH_MAX_PER_CONVERSATION) break;
4710
+ }
4711
+ if (matchCount > 0 && snippet) {
4712
+ hits.push({
4713
+ conversationId: conv.id,
4714
+ cwd: conv.cwd,
4715
+ lastActivityAt: conv.lastActivityAt,
4716
+ matchCount,
4717
+ snippet
4718
+ });
4719
+ }
4720
+ }
4721
+ }
4722
+ for (const path2 of [...searchCache.keys()]) {
4723
+ if (!liveFiles.has(path2)) searchCache.delete(path2);
4724
+ }
4725
+ hits.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
4726
+ return hits.slice(0, SEARCH_MAX_RESULTS);
4727
+ }
4284
4728
  const CREDENTIALS_PATH = path.join(os.homedir(), ".claude", ".credentials.json");
4285
4729
  const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
4286
4730
  const OAUTH_BETA = "oauth-2025-04-20";
@@ -4357,216 +4801,74 @@ class UsageLimitsService {
4357
4801
  };
4358
4802
  }
4359
4803
  }
4360
- const PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
4361
- const BLOCK_MS = 5 * 60 * 60 * 1e3;
4362
- const HOUR_MS = 60 * 60 * 1e3;
4363
- const PRICING = [
4364
- { match: /fable/, input: 10, output: 50 },
4365
- { match: /opus-4-[5-9]/, input: 5, output: 25 },
4366
- { match: /opus/, input: 15, output: 75 },
4367
- { match: /sonnet/, input: 3, output: 15 },
4368
- { match: /haiku-4/, input: 1, output: 5 },
4369
- { match: /3-5-haiku|haiku-3-5/, input: 0.8, output: 4 },
4370
- { match: /haiku/, input: 0.25, output: 1.25 }
4371
- ];
4372
- function zeroTotals() {
4373
- return { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, costUSD: 0 };
4374
- }
4375
- function add(t, e) {
4376
- t.inputTokens += e.input;
4377
- t.outputTokens += e.output;
4378
- t.cacheWriteTokens += e.cacheWrite5m + e.cacheWrite1h;
4379
- t.cacheReadTokens += e.cacheRead;
4380
- t.costUSD += e.costUSD;
4381
- }
4382
- function localDay(at) {
4804
+ function localDay$1(at) {
4383
4805
  const d = new Date(at);
4384
4806
  const m = String(d.getMonth() + 1).padStart(2, "0");
4385
4807
  const day = String(d.getDate()).padStart(2, "0");
4386
4808
  return `${d.getFullYear()}-${m}-${day}`;
4387
4809
  }
4388
- function computeCost(entry) {
4389
- const price = PRICING.find((p) => p.match.test(entry.model));
4390
- if (!price) return 0;
4391
- return (entry.input * price.input + entry.output * price.output + entry.cacheWrite5m * price.input * 1.25 + entry.cacheWrite1h * price.input * 2 + entry.cacheRead * price.input * 0.1) / 1e6;
4392
- }
4393
- function computeProjection(points, now) {
4394
- if (points.length === 0) return null;
4395
- points.sort((a, b) => a.at - b.at);
4396
- const blocks = [];
4397
- let cur = null;
4398
- for (const p of points) {
4399
- if (!cur || p.at >= cur.start + BLOCK_MS || p.at - cur.lastAt >= BLOCK_MS) {
4400
- cur = { start: Math.floor(p.at / HOUR_MS) * HOUR_MS, firstAt: p.at, lastAt: p.at, tokens: 0 };
4401
- blocks.push(cur);
4402
- }
4403
- cur.lastAt = p.at;
4404
- cur.tokens += p.tokens;
4810
+ class BudgetAlerts {
4811
+ constructor(getWin2) {
4812
+ this.getWin = getWin2;
4405
4813
  }
4406
- const last = blocks[blocks.length - 1];
4407
- if (now < last.start || now >= last.start + BLOCK_MS) return null;
4408
- const blockEndAt = last.start + BLOCK_MS;
4409
- let maxBlockTokens = 0;
4410
- for (const b of blocks) {
4411
- if (b !== last && b.tokens > maxBlockTokens) maxBlockTokens = b.tokens;
4814
+ daily = { period: "", fired: /* @__PURE__ */ new Set() };
4815
+ monthly = { period: "", fired: /* @__PURE__ */ new Set() };
4816
+ /** Re-evaluate both budgets against the latest snapshot; fire any new alerts. */
4817
+ check(snap, settings) {
4818
+ const threshold = clampThreshold(settings.budgetAlertThreshold);
4819
+ const day = localDay$1(snap.updatedAt);
4820
+ this.checkBudget(this.daily, "daily", day, snap.today.costUSD, settings.dailyBudgetUSD, threshold);
4821
+ this.checkBudget(
4822
+ this.monthly,
4823
+ "monthly",
4824
+ day.slice(0, 7),
4825
+ snap.month.costUSD,
4826
+ settings.monthlyBudgetUSD,
4827
+ threshold
4828
+ );
4412
4829
  }
4413
- const elapsedMin = Math.max(1, (now - last.firstAt) / 6e4);
4414
- const tokensPerMin = last.tokens / elapsedMin;
4415
- let runsOutAt = null;
4416
- if (maxBlockTokens > 0) {
4417
- if (last.tokens >= maxBlockTokens) {
4418
- runsOutAt = now;
4419
- } else if (tokensPerMin > 0) {
4420
- const eta = now + (maxBlockTokens - last.tokens) / tokensPerMin * 6e4;
4421
- if (eta < blockEndAt) runsOutAt = eta;
4830
+ checkBudget(state, scope, period, spend, budget, thresholdPct) {
4831
+ if (budget == null || budget <= 0) return;
4832
+ if (state.period !== period) {
4833
+ state.period = period;
4834
+ state.fired.clear();
4835
+ }
4836
+ if (spend <= 0) return;
4837
+ const thresholdValue = budget * thresholdPct / 100;
4838
+ if (spend >= budget) {
4839
+ if (!state.fired.has("limit")) {
4840
+ this.fire(scope, "limit", spend, budget);
4841
+ state.fired.add("limit");
4842
+ state.fired.add("threshold");
4843
+ }
4844
+ } else if (spend >= thresholdValue) {
4845
+ if (!state.fired.has("threshold")) {
4846
+ this.fire(scope, "threshold", spend, budget);
4847
+ state.fired.add("threshold");
4848
+ }
4422
4849
  }
4423
4850
  }
4424
- return {
4425
- blockStartAt: last.start,
4426
- blockEndAt,
4427
- blockTokens: last.tokens,
4428
- maxBlockTokens,
4429
- tokensPerMin,
4430
- runsOutAt
4431
- };
4432
- }
4433
- function parseFile(path2) {
4434
- let raw;
4435
- try {
4436
- raw = fs.readFileSync(path2, "utf8");
4437
- } catch {
4438
- return [];
4851
+ fire(scope, level, spend, budget) {
4852
+ const win2 = this.getWin();
4853
+ const pct = Math.round(spend / budget * 100);
4854
+ const label = scope === "daily" ? "Daily" : "Monthly";
4855
+ const title = level === "limit" ? `${label} spend budget reached` : `${label} spend budget alert`;
4856
+ const body = `Spent ${fmtUSD(spend)} of your ${fmtUSD(budget)} ${scope} budget (${pct}%).`;
4857
+ if (!(win2?.isFocused() ?? false)) win2?.flashFrame(true);
4858
+ const notification = new electron.Notification({ title, body });
4859
+ notification.on("click", () => {
4860
+ win2?.show();
4861
+ win2?.focus();
4862
+ });
4863
+ notification.show();
4439
4864
  }
4440
- const entries = [];
4441
- for (const line of raw.split("\n")) {
4442
- if (!line.includes('"assistant"') || !line.includes('"usage"')) continue;
4443
- let obj;
4444
- try {
4445
- obj = JSON.parse(line);
4446
- } catch {
4447
- continue;
4448
- }
4449
- if (obj.type !== "assistant") continue;
4450
- const message = obj.message;
4451
- const usage = message?.usage;
4452
- const model = typeof message?.model === "string" ? message.model : "";
4453
- if (!usage || !model || model === "<synthetic>") continue;
4454
- const num = (v) => typeof v === "number" && isFinite(v) ? v : 0;
4455
- const cacheCreation = usage.cache_creation;
4456
- const totalWrite = num(usage.cache_creation_input_tokens);
4457
- const write1h = num(cacheCreation?.ephemeral_1h_input_tokens);
4458
- const write5m = cacheCreation ? num(cacheCreation.ephemeral_5m_input_tokens) : totalWrite;
4459
- const at = Date.parse(typeof obj.timestamp === "string" ? obj.timestamp : "") || 0;
4460
- const msgId = typeof message?.id === "string" ? message.id : obj.uuid ?? "";
4461
- const entry = {
4462
- key: `${msgId}:${typeof obj.requestId === "string" ? obj.requestId : ""}`,
4463
- day: localDay(at),
4464
- at,
4465
- model,
4466
- input: num(usage.input_tokens),
4467
- output: num(usage.output_tokens),
4468
- cacheWrite5m: write5m,
4469
- cacheWrite1h: write1h,
4470
- cacheRead: num(usage.cache_read_input_tokens),
4471
- costUSD: 0
4472
- };
4473
- const precomputed = num(obj.costUSD);
4474
- entry.costUSD = precomputed > 0 ? precomputed : computeCost(entry);
4475
- entries.push(entry);
4476
- }
4477
- return entries;
4478
4865
  }
4479
- class UsageService {
4480
- fileCache = /* @__PURE__ */ new Map();
4481
- snapshot() {
4482
- const now = Date.now();
4483
- const today = localDay(now);
4484
- const monthPrefix = today.slice(0, 7);
4485
- const total = zeroTotals();
4486
- const todayTotals = zeroTotals();
4487
- const month = zeroTotals();
4488
- const perModel = /* @__PURE__ */ new Map();
4489
- const perProject = [];
4490
- const seen = /* @__PURE__ */ new Set();
4491
- const liveFiles = /* @__PURE__ */ new Set();
4492
- const points = [];
4493
- let projectDirs = [];
4494
- try {
4495
- if (fs.existsSync(PROJECTS_DIR)) projectDirs = fs.readdirSync(PROJECTS_DIR);
4496
- } catch {
4497
- projectDirs = [];
4498
- }
4499
- for (const dir of projectDirs) {
4500
- const dirPath = path.join(PROJECTS_DIR, dir);
4501
- let files;
4502
- try {
4503
- files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".jsonl"));
4504
- } catch {
4505
- continue;
4506
- }
4507
- const project = {
4508
- dir,
4509
- total: zeroTotals(),
4510
- today: zeroTotals(),
4511
- lastActivityAt: 0
4512
- };
4513
- for (const file of files) {
4514
- const path$1 = path.join(dirPath, file);
4515
- liveFiles.add(path$1);
4516
- for (const entry of this.entriesFor(path$1)) {
4517
- if (seen.has(entry.key)) continue;
4518
- seen.add(entry.key);
4519
- if (entry.at > 0) {
4520
- points.push({
4521
- at: entry.at,
4522
- tokens: entry.input + entry.output + entry.cacheWrite5m + entry.cacheWrite1h + entry.cacheRead
4523
- });
4524
- }
4525
- add(total, entry);
4526
- add(project.total, entry);
4527
- if (entry.day === today) {
4528
- add(todayTotals, entry);
4529
- add(project.today, entry);
4530
- }
4531
- if (entry.day.startsWith(monthPrefix)) add(month, entry);
4532
- let m = perModel.get(entry.model);
4533
- if (!m) perModel.set(entry.model, m = zeroTotals());
4534
- add(m, entry);
4535
- if (entry.at > project.lastActivityAt) project.lastActivityAt = entry.at;
4536
- }
4537
- }
4538
- if (project.total.costUSD > 0 || project.total.outputTokens > 0) perProject.push(project);
4539
- }
4540
- for (const path2 of [...this.fileCache.keys()]) {
4541
- if (!liveFiles.has(path2)) this.fileCache.delete(path2);
4542
- }
4543
- perProject.sort((a, b) => b.total.costUSD - a.total.costUSD);
4544
- return {
4545
- total,
4546
- today: todayTotals,
4547
- month,
4548
- perProject,
4549
- perModel: [...perModel.entries()].map(([model, totals]) => ({ model, totals })).sort((a, b) => b.totals.costUSD - a.totals.costUSD),
4550
- projection: computeProjection(points, now),
4551
- updatedAt: now
4552
- };
4553
- }
4554
- entriesFor(path2) {
4555
- let stat;
4556
- try {
4557
- stat = fs.statSync(path2);
4558
- } catch {
4559
- this.fileCache.delete(path2);
4560
- return [];
4561
- }
4562
- const cached = this.fileCache.get(path2);
4563
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
4564
- return cached.entries;
4565
- }
4566
- const entries = parseFile(path2);
4567
- this.fileCache.set(path2, { mtimeMs: stat.mtimeMs, size: stat.size, entries });
4568
- return entries;
4569
- }
4866
+ function clampThreshold(pct) {
4867
+ if (typeof pct !== "number" || !isFinite(pct) || pct <= 0) return 80;
4868
+ return Math.min(100, pct);
4869
+ }
4870
+ function fmtUSD(v) {
4871
+ return `$${v.toFixed(2)}`;
4570
4872
  }
4571
4873
  const CONDUCTOR_ATTACH_SCOPE = "conductor";
4572
4874
  function tokenize(template) {
@@ -4576,7 +4878,7 @@ function tokenize(template) {
4576
4878
  while (m = re.exec(template)) tokens.push(m[1] ?? m[2]);
4577
4879
  return tokens;
4578
4880
  }
4579
- function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, gamification, getWin2) {
4881
+ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, gamification, usage, getWin2) {
4580
4882
  const rootOf = (id) => {
4581
4883
  const config = sessions.getConfig(id);
4582
4884
  if (!config) throw new Error(`Unknown session: ${id}`);
@@ -4634,6 +4936,23 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4634
4936
  (_e, sessionId, path2) => sessions.getGitFileDiff(sessionId, path2)
4635
4937
  );
4636
4938
  electron.ipcMain.handle("git:branches", (_e, sessionId) => sessions.listBranches(sessionId));
4939
+ electron.ipcMain.handle("git:branchDiff", (_e, sessionId) => sessions.getBranchDiff(sessionId));
4940
+ electron.ipcMain.handle(
4941
+ "git:checkout",
4942
+ (_e, sessionId, branch) => sessions.checkoutSessionBranch(sessionId, branch)
4943
+ );
4944
+ electron.ipcMain.handle(
4945
+ "git:createBranch",
4946
+ (_e, sessionId, branch) => sessions.createSessionBranch(sessionId, branch)
4947
+ );
4948
+ electron.ipcMain.handle(
4949
+ "git:deleteBranch",
4950
+ (_e, sessionId, branch) => sessions.deleteSessionBranch(sessionId, branch)
4951
+ );
4952
+ electron.ipcMain.handle(
4953
+ "git:createBranchPr",
4954
+ (_e, sessionId) => sessions.createBranchPr(sessionId)
4955
+ );
4637
4956
  electron.ipcMain.handle(
4638
4957
  "checkpoint:create",
4639
4958
  (_e, sessionId, label) => sessions.createCheckpoint(sessionId, label)
@@ -4642,6 +4961,10 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4642
4961
  "checkpoint:list",
4643
4962
  (_e, sessionId) => sessions.listCheckpoints(sessionId)
4644
4963
  );
4964
+ electron.ipcMain.handle(
4965
+ "checkpoint:diff",
4966
+ (_e, sessionId, id) => sessions.getCheckpointDiff(sessionId, id)
4967
+ );
4645
4968
  electron.ipcMain.handle(
4646
4969
  "checkpoint:restore",
4647
4970
  (_e, sessionId, id) => sessions.restoreCheckpoint(sessionId, id)
@@ -4913,10 +5236,16 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4913
5236
  "tokenEff:detectTools",
4914
5237
  (_e, refresh) => tokenEff.detectTools(refresh ?? false)
4915
5238
  );
4916
- const usage = new UsageService();
4917
- electron.ipcMain.handle("usage:get", () => usage.snapshot());
5239
+ const budgetAlerts = new BudgetAlerts(getWin2);
5240
+ electron.ipcMain.handle("usage:get", () => {
5241
+ const snap = usage.snapshot();
5242
+ budgetAlerts.check(snap, persistence2.state.settings);
5243
+ return snap;
5244
+ });
4918
5245
  const usageLimits = new UsageLimitsService();
4919
5246
  electron.ipcMain.handle("usage:limits", () => usageLimits.limits());
5247
+ electron.ipcMain.handle("conversations:list", (_e, folder) => listConversations(folder));
5248
+ electron.ipcMain.handle("conversations:search", (_e, query) => searchConversations(query));
4920
5249
  electron.ipcMain.handle(
4921
5250
  "transcript:export",
4922
5251
  async (_e, sessionId, fileName, content) => {
@@ -4951,6 +5280,23 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4951
5280
  getWin2()?.webContents.send("session:changed");
4952
5281
  if (patch.agentRegistryPath !== void 0) agents.refresh();
4953
5282
  });
5283
+ electron.ipcMain.handle("github:listAccounts", () => listAccounts());
5284
+ electron.ipcMain.handle("github:repoInfo", async (_e, sessionId) => {
5285
+ const slug = await repoSlug(rootOf(sessionId));
5286
+ const accounts = await listAccounts();
5287
+ const current = slug ? persistence2.state.settings.githubRepoAccounts[slug] ?? null : null;
5288
+ return { slug, current, accounts };
5289
+ });
5290
+ electron.ipcMain.handle(
5291
+ "github:setRepoAccount",
5292
+ (_e, slug, account) => {
5293
+ const map = persistence2.state.settings.githubRepoAccounts;
5294
+ if (account) map[slug] = account;
5295
+ else delete map[slug];
5296
+ persistence2.scheduleSave();
5297
+ getWin2()?.webContents.send("session:changed");
5298
+ }
5299
+ );
4954
5300
  }
4955
5301
  const DEFAULT_TOKEN_EFFICIENCY = {
4956
5302
  enabled: false,
@@ -4982,7 +5328,11 @@ const DEFAULT_SETTINGS = {
4982
5328
  accentColor: null,
4983
5329
  gamificationEnabled: true,
4984
5330
  gamificationReduceMotion: false,
4985
- gamificationSound: false
5331
+ gamificationSound: false,
5332
+ dailyBudgetUSD: null,
5333
+ monthlyBudgetUSD: null,
5334
+ budgetAlertThreshold: 80,
5335
+ githubRepoAccounts: {}
4986
5336
  };
4987
5337
  const DEFAULT_CATEGORIES = [
4988
5338
  {
@@ -5587,6 +5937,8 @@ const ACTION_SHELL_READY_MS = 1200;
5587
5937
  const CLAUDE_SUBMIT_DELAY_MS = 300;
5588
5938
  const QUEUE_IDLE_DELAY_MS = 3e3;
5589
5939
  const AUTO_COMPLETE_IDLE_DELAY_MS = 9e4;
5940
+ const PLAN_APPROVAL_RE = /(?:auto-accept edits|keep planning|manually approve)/i;
5941
+ const PLAN_ACCEPT_DELAY_MS = 600;
5590
5942
  const WATCHDOG_TICK_MS = 5e3;
5591
5943
  const SCROLLBACK_DIVIDER = "\r\n\x1B[0m\x1B[2m── restored from previous session ──\x1B[0m\r\n\r\n";
5592
5944
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -5635,6 +5987,10 @@ class SessionManager {
5635
5987
  autoCompleteTimers = /* @__PURE__ */ new Map();
5636
5988
  /** Worktree session ids with an auto-complete action in flight (fires once). */
5637
5989
  autoCompleteInFlight = /* @__PURE__ */ new Set();
5990
+ /** Worktree session ids whose current plan-approval prompt Maestro has already
5991
+ * answered — re-armed when claude goes back to working, so each distinct plan
5992
+ * is auto-accepted exactly once. */
5993
+ planAccepted = /* @__PURE__ */ new Set();
5638
5994
  /** On-disk tail of each terminal's output, replayed on app restart. */
5639
5995
  scrollback = new ScrollbackStore();
5640
5996
  /**
@@ -5699,14 +6055,15 @@ class SessionManager {
5699
6055
  claudeArgs: [],
5700
6056
  startMode: "continue"
5701
6057
  };
6058
+ const terminals = opts?.terminals ?? [claudeTerminal];
5702
6059
  const config = {
5703
6060
  id: crypto.randomUUID(),
5704
6061
  name: opts?.name ?? path.basename(folder) ?? folder,
5705
6062
  folder,
5706
6063
  color: opts?.color ?? null,
5707
6064
  order: Math.max(0, ...this.state.sessions.map((s) => s.order + 1)),
5708
- terminals: opts?.terminals ?? [claudeTerminal],
5709
- activeTerminalId: claudeTerminal.id,
6065
+ terminals,
6066
+ activeTerminalId: terminals[0]?.id ?? null,
5710
6067
  expandedPaths: [],
5711
6068
  categoryId: opts?.categoryId ?? null
5712
6069
  };
@@ -5786,6 +6143,131 @@ class SessionManager {
5786
6143
  if (!config) return { branches: [], current: null, defaultBranch: null };
5787
6144
  return listBranches(config.folder);
5788
6145
  }
6146
+ /**
6147
+ * Switch a session's folder to an existing branch. Warn-and-block: refuses on
6148
+ * any uncommitted change so the user's work is never disturbed (they commit,
6149
+ * stash, or checkpoint first). git's own message is surfaced when the branch
6150
+ * is busy in another worktree.
6151
+ */
6152
+ async checkoutSessionBranch(sessionId, branch) {
6153
+ const config = this.getConfig(sessionId);
6154
+ if (!config) return { ok: false, reason: "error", output: "Unknown session." };
6155
+ const dirty = await dirtyCount(config.folder) ?? 0;
6156
+ if (dirty > 0) {
6157
+ return {
6158
+ ok: false,
6159
+ reason: "dirty",
6160
+ output: `This folder has ${dirty} uncommitted file(s). Commit, stash, or take a checkpoint before switching branches, so nothing is lost.`
6161
+ };
6162
+ }
6163
+ const res = await checkoutBranch(config.folder, branch);
6164
+ if (res.code !== 0) {
6165
+ const busy = /already (checked out|used by worktree)/i.test(res.output);
6166
+ return { ok: false, reason: busy ? "busy" : "error", output: res.output };
6167
+ }
6168
+ this.notifyChanged();
6169
+ return { ok: true, output: res.output };
6170
+ }
6171
+ /**
6172
+ * Create a new branch off the session folder's current HEAD and switch to it.
6173
+ * Same warn-and-block dirty guard as checkout; validates the branch name and
6174
+ * reports a name clash distinctly so the UI can say "already exists".
6175
+ */
6176
+ async createSessionBranch(sessionId, branch) {
6177
+ const config = this.getConfig(sessionId);
6178
+ if (!config) return { ok: false, reason: "error", output: "Unknown session." };
6179
+ const name = branch.trim();
6180
+ if (!await isValidBranchName(name)) {
6181
+ return { ok: false, reason: "invalid", output: `"${name}" is not a valid branch name.` };
6182
+ }
6183
+ if (await branchExists(config.folder, name)) {
6184
+ return { ok: false, reason: "exists", output: `Branch "${name}" already exists.` };
6185
+ }
6186
+ const dirty = await dirtyCount(config.folder) ?? 0;
6187
+ if (dirty > 0) {
6188
+ return {
6189
+ ok: false,
6190
+ reason: "dirty",
6191
+ output: `This folder has ${dirty} uncommitted file(s). Commit, stash, or take a checkpoint before creating a branch (it switches the working tree).`
6192
+ };
6193
+ }
6194
+ const res = await createBranch(config.folder, name);
6195
+ if (res.code !== 0) return { ok: false, reason: "error", output: res.output };
6196
+ this.notifyChanged();
6197
+ return { ok: true, output: res.output };
6198
+ }
6199
+ /**
6200
+ * Delete a local branch in a session's repo. Refuses to delete the branch the
6201
+ * folder currently has checked out, or one checked out in another worktree.
6202
+ */
6203
+ async deleteSessionBranch(sessionId, branch) {
6204
+ const config = this.getConfig(sessionId);
6205
+ if (!config) return { ok: false, reason: "error", output: "Unknown session." };
6206
+ const listing = await listBranches(config.folder);
6207
+ if (listing.current === branch) {
6208
+ return {
6209
+ ok: false,
6210
+ reason: "busy",
6211
+ output: `"${branch}" is the current branch — switch away before deleting it.`
6212
+ };
6213
+ }
6214
+ const busyAt = await branchCheckedOutAt(config.folder, branch);
6215
+ if (busyAt) {
6216
+ return {
6217
+ ok: false,
6218
+ reason: "busy",
6219
+ output: `"${branch}" is checked out in another worktree (${busyAt}) — close it first.`
6220
+ };
6221
+ }
6222
+ await deleteBranch(config.folder, branch);
6223
+ this.notifyChanged();
6224
+ return { ok: true, output: `Deleted branch "${branch}".` };
6225
+ }
6226
+ /**
6227
+ * Open a pull request for a NON-worktree session's current branch against the
6228
+ * repo's default branch. Pushes the branch (via the per-repo GitHub account),
6229
+ * then `gh pr create`. The worktree equivalent is createWorktreePr.
6230
+ */
6231
+ async createBranchPr(sessionId) {
6232
+ const config = this.getConfig(sessionId);
6233
+ if (!config) return { ok: false, output: "Unknown session." };
6234
+ const listing = await listBranches(config.folder);
6235
+ const branch = listing.current;
6236
+ if (!branch) {
6237
+ return { ok: false, output: "No branch is checked out (detached HEAD) — cannot open a PR." };
6238
+ }
6239
+ const baseBranch = listing.defaultBranch && listing.defaultBranch !== branch ? listing.defaultBranch : null;
6240
+ if (!baseBranch) {
6241
+ return {
6242
+ ok: false,
6243
+ output: `The current branch "${branch}" is the default branch — there is nothing to open a PR against. Switch to a feature branch first.`
6244
+ };
6245
+ }
6246
+ const title = branch;
6247
+ const body = `Opened by Maestro for branch \`${branch}\`.
6248
+
6249
+ Merges \`${branch}\` into \`${baseBranch}\`.`;
6250
+ const env = await accountEnvForRepo(
6251
+ config.folder,
6252
+ this.state.settings.githubRepoAccounts
6253
+ );
6254
+ const result = await createPullRequest(config.folder, branch, baseBranch, title, body, env);
6255
+ if (result.ok) this.emitGame({ type: "worktree.pr" });
6256
+ return result;
6257
+ }
6258
+ /**
6259
+ * Cumulative diff of a worktree task's branch against its base branch (their
6260
+ * merge-base), for the read-only "Review changes" tab. Returns an empty diff
6261
+ * for a non-worktree session — the UI only offers this on worktree tasks.
6262
+ */
6263
+ async getBranchDiff(sessionId) {
6264
+ const config = this.getConfig(sessionId);
6265
+ const wt = config?.worktree;
6266
+ if (!config || !wt) {
6267
+ return { diff: "", truncated: false, files: [], branch: "", baseBranch: "" };
6268
+ }
6269
+ return branchDiff(config.folder, wt.branch, wt.baseBranch);
6270
+ }
5789
6271
  /**
5790
6272
  * Initialize a git repository in a session's folder (so a non-repo session can
5791
6273
  * host parallel tasks). Returns the resulting git facts; throws git's message
@@ -5816,6 +6298,12 @@ class SessionManager {
5816
6298
  if (!config) return [];
5817
6299
  return listCheckpoints(config.folder);
5818
6300
  }
6301
+ /** Read-only diff between a checkpoint and the current working tree. */
6302
+ async getCheckpointDiff(sessionId, id) {
6303
+ const config = this.getConfig(sessionId);
6304
+ if (!config) return { diff: "", binary: false, truncated: false };
6305
+ return checkpointDiff(config.folder, id);
6306
+ }
5819
6307
  /** Restore a session's working tree back to a checkpoint (guarded, reversible). */
5820
6308
  async restoreCheckpoint(sessionId, id) {
5821
6309
  const config = this.getConfig(sessionId);
@@ -5868,13 +6356,15 @@ class SessionManager {
5868
6356
  console.error("copyWorktreeIncludes failed", err);
5869
6357
  }
5870
6358
  }
6359
+ const claudeArgs = [];
6360
+ if (opts.plan) claudeArgs.push("--permission-mode", "plan");
6361
+ if (opts.model) claudeArgs.push("--model", opts.model);
5871
6362
  const claudeTerminal = {
5872
6363
  id: crypto.randomUUID(),
5873
6364
  kind: "claude",
5874
6365
  title: "claude",
5875
6366
  order: 0,
5876
- // A model picked for the task pins its claude via --model; absent = CLI default.
5877
- claudeArgs: opts.model ? ["--model", opts.model] : [],
6367
+ claudeArgs,
5878
6368
  startMode: "fresh"
5879
6369
  };
5880
6370
  const config = {
@@ -5895,7 +6385,9 @@ class SessionManager {
5895
6385
  // Default to direct merge; only persist a PR/auto choice when set, so
5896
6386
  // existing tasks and the common case stay exactly as before.
5897
6387
  ...opts.completion && opts.completion !== "merge" ? { completion: opts.completion } : {},
5898
- ...opts.autoComplete ? { autoComplete: true } : {}
6388
+ ...opts.autoComplete ? { autoComplete: true } : {},
6389
+ ...opts.plan ? { plan: true } : {},
6390
+ ...opts.plan && opts.autoAcceptPlan ? { autoAcceptPlan: true } : {}
5899
6391
  }
5900
6392
  };
5901
6393
  this.state.sessions.push(config);
@@ -5923,7 +6415,14 @@ class SessionManager {
5923
6415
  async getWorktreeTaskState(sessionId) {
5924
6416
  const config = this.getConfig(sessionId);
5925
6417
  if (!config?.worktree) {
5926
- return { folderExists: false, dirty: -1, ahead: -1, conflictFiles: null };
6418
+ return {
6419
+ folderExists: false,
6420
+ dirty: -1,
6421
+ ahead: -1,
6422
+ conflictFiles: null,
6423
+ baseDirty: -1,
6424
+ baseBranchBusyPath: null
6425
+ };
5927
6426
  }
5928
6427
  const folderExists = fs.existsSync(config.folder);
5929
6428
  const dirty = folderExists ? await dirtyCount(config.folder) : null;
@@ -5932,6 +6431,13 @@ class SessionManager {
5932
6431
  config.worktree.baseBranch,
5933
6432
  config.worktree.branch
5934
6433
  );
6434
+ const baseDirty = await dirtyCount(config.worktree.baseFolder);
6435
+ const busyAt = await branchCheckedOutAt(
6436
+ config.worktree.baseFolder,
6437
+ config.worktree.baseBranch
6438
+ );
6439
+ const baseNorm = config.worktree.baseFolder.replace(/\\/g, "/").replace(/\/+$/, "");
6440
+ const baseBranchBusyPath = busyAt && busyAt.replace(/\/+$/, "") !== baseNorm ? busyAt : null;
5935
6441
  let conflictFiles = null;
5936
6442
  if (ahead !== null && ahead > 0) {
5937
6443
  try {
@@ -5946,7 +6452,14 @@ class SessionManager {
5946
6452
  } else if (ahead === 0) {
5947
6453
  conflictFiles = [];
5948
6454
  }
5949
- return { folderExists, dirty: dirty ?? -1, ahead: ahead ?? -1, conflictFiles };
6455
+ return {
6456
+ folderExists,
6457
+ dirty: dirty ?? -1,
6458
+ ahead: ahead ?? -1,
6459
+ conflictFiles,
6460
+ baseDirty: baseDirty ?? -1,
6461
+ baseBranchBusyPath
6462
+ };
5950
6463
  }
5951
6464
  /**
5952
6465
  * Merge a worktree task's branch back into its base branch (runs in the base
@@ -6020,9 +6533,13 @@ ${commit.output}` };
6020
6533
  * and push to GitHub", so if it has no upstream yet we publish it (push -u).
6021
6534
  */
6022
6535
  async pushAfterMerge(merge, baseFolder, baseBranch) {
6023
- let push = await pushBranch(baseFolder, baseBranch);
6536
+ const env = await accountEnvForRepo(
6537
+ baseFolder,
6538
+ this.state.settings.githubRepoAccounts
6539
+ );
6540
+ let push = await pushBranch(baseFolder, baseBranch, env);
6024
6541
  if (!push && this.isAutoExpandBranch(baseBranch)) {
6025
- push = await publishBranch(baseFolder, baseBranch);
6542
+ push = await publishBranch(baseFolder, baseBranch, env);
6026
6543
  }
6027
6544
  if (!push) return merge;
6028
6545
  if (push.ok) return { ...merge, pushed: true };
@@ -6097,17 +6614,21 @@ ${commit.output}` };
6097
6614
  }
6098
6615
  const title = prTitle(config.name, branch);
6099
6616
  const body = prBody(config.name, branch, baseBranch);
6100
- const result = await createPullRequest(config.folder, branch, baseBranch, title, body);
6617
+ const env = await accountEnvForRepo(
6618
+ config.folder,
6619
+ this.state.settings.githubRepoAccounts
6620
+ );
6621
+ const result = await createPullRequest(config.folder, branch, baseBranch, title, body, env);
6101
6622
  if (result.ok) this.emitGame({ type: "worktree.pr" });
6102
6623
  return { ...result, autoCommitted };
6103
6624
  }
6104
6625
  // ---------- auto-complete (auto-merge / auto-PR when claude finishes) --------
6105
6626
  /**
6106
6627
  * (Re)start the auto-complete idle countdown for a worktree task whose claude
6107
- * just went idle. Only arms once the task's claude has actually worked (so the
6108
- * boot-time idle never counts), and never while a prompt queue is still
6109
- * draining or an action is already in flight / done. The fire handler
6110
- * re-checks everything, so a terminal that wakes mid-countdown is safe.
6628
+ * just settled (done/idle). Only arms once the task's claude has actually
6629
+ * worked (so the boot-time settle never counts), and never while a prompt
6630
+ * queue is still draining or an action is already in flight / done. The fire
6631
+ * handler re-checks everything, so a terminal that wakes mid-countdown is safe.
6111
6632
  */
6112
6633
  scheduleAutoComplete(sessionId) {
6113
6634
  const config = this.getConfig(sessionId);
@@ -6131,6 +6652,28 @@ ${commit.output}` };
6131
6652
  if (timer) clearTimeout(timer);
6132
6653
  this.autoCompleteTimers.delete(sessionId);
6133
6654
  }
6655
+ /**
6656
+ * Auto-approve the plan-mode prompt for an opted-in task. Runs once per
6657
+ * attention episode (StatusDetector doesn't re-emit a held status, so a single
6658
+ * timer per episode is enough). The regex is evaluated only AFTER a short
6659
+ * settle — the terminal bell that raises attention can land a beat before the
6660
+ * menu text finishes rendering — and only then, if the approval menu is
6661
+ * actually on screen and not already handled, Maestro presses Enter to take
6662
+ * the default option ("Yes, and auto-accept edits"). Non-plan attention
6663
+ * prompts simply don't match the regex and are left for the user.
6664
+ * `planAccepted` is cleared when claude resumes working, so a later plan (it
6665
+ * can re-enter plan mode) is accepted too.
6666
+ */
6667
+ maybeAutoAcceptPlan(sessionId, terminalId) {
6668
+ if (this.planAccepted.has(sessionId)) return;
6669
+ setTimeout(() => {
6670
+ const live = this.ptys.get(terminalId);
6671
+ if (!live?.alive || this.planAccepted.has(sessionId)) return;
6672
+ if (!PLAN_APPROVAL_RE.test(live.detector.recentText)) return;
6673
+ this.planAccepted.add(sessionId);
6674
+ live.write("\r");
6675
+ }, PLAN_ACCEPT_DELAY_MS);
6676
+ }
6134
6677
  /**
6135
6678
  * Run a task's chosen completion automatically. Re-checks the gates that may
6136
6679
  * have changed during the countdown (still a worktree task, claude alive and
@@ -6146,7 +6689,8 @@ ${commit.output}` };
6146
6689
  if (config.promptQueue?.length) return;
6147
6690
  const terminal = this.claudeTargetTerminal(config);
6148
6691
  const pty2 = terminal ? this.ptys.get(terminal.id) : null;
6149
- if (!pty2?.alive || pty2.detector.current !== "idle") return;
6692
+ const settled = pty2?.detector.current === "done" || pty2?.detector.current === "idle";
6693
+ if (!pty2?.alive || !settled) return;
6150
6694
  const dirty = await dirtyCount(config.folder) ?? 0;
6151
6695
  const ahead = await aheadCount(wt.baseFolder, wt.baseBranch, wt.branch) ?? 0;
6152
6696
  if (dirty === 0 && ahead === 0) return;
@@ -6264,6 +6808,7 @@ ${err.message}`
6264
6808
  this.tokenEff.clearApplied(sessionId);
6265
6809
  this.worktreeWorked.delete(sessionId);
6266
6810
  this.autoCompleteInFlight.delete(sessionId);
6811
+ this.planAccepted.delete(sessionId);
6267
6812
  this.state.sessions = this.state.sessions.filter((s) => s.id !== sessionId);
6268
6813
  if (this.state.activeSessionId === sessionId) this.state.activeSessionId = null;
6269
6814
  this.persistence.scheduleSave();
@@ -6634,9 +7179,13 @@ ${err.message}`
6634
7179
  if (status === "done" || status === "idle") this.scheduleQueueDispatch(config.id);
6635
7180
  if (config.worktree?.autoComplete) {
6636
7181
  if (status === "working") this.worktreeWorked.add(config.id);
6637
- if (status === "idle") this.scheduleAutoComplete(config.id);
7182
+ if (status === "done" || status === "idle") this.scheduleAutoComplete(config.id);
6638
7183
  else this.clearAutoCompleteTimer(config.id);
6639
7184
  }
7185
+ if (config.worktree?.autoAcceptPlan) {
7186
+ if (status === "working") this.planAccepted.delete(config.id);
7187
+ if (status === "needs-attention") this.maybeAutoAcceptPlan(config.id, terminalId);
7188
+ }
6640
7189
  if (status !== "needs-attention") return;
6641
7190
  const focused = win2?.isFocused() ?? false;
6642
7191
  const isActive = this.state.activeSessionId === config.id && config.activeTerminalId === terminalId;
@@ -7573,6 +8122,217 @@ class TokenEfficiencyService {
7573
8122
  }
7574
8123
  }
7575
8124
  }
8125
+ const PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
8126
+ const BLOCK_MS = 5 * 60 * 60 * 1e3;
8127
+ const HOUR_MS = 60 * 60 * 1e3;
8128
+ const PRICING = [
8129
+ { match: /fable/, input: 10, output: 50 },
8130
+ { match: /opus-4-[5-9]/, input: 5, output: 25 },
8131
+ { match: /opus/, input: 15, output: 75 },
8132
+ { match: /sonnet/, input: 3, output: 15 },
8133
+ { match: /haiku-4/, input: 1, output: 5 },
8134
+ { match: /3-5-haiku|haiku-3-5/, input: 0.8, output: 4 },
8135
+ { match: /haiku/, input: 0.25, output: 1.25 }
8136
+ ];
8137
+ function zeroTotals() {
8138
+ return { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, costUSD: 0 };
8139
+ }
8140
+ function add(t, e) {
8141
+ t.inputTokens += e.input;
8142
+ t.outputTokens += e.output;
8143
+ t.cacheWriteTokens += e.cacheWrite5m + e.cacheWrite1h;
8144
+ t.cacheReadTokens += e.cacheRead;
8145
+ t.costUSD += e.costUSD;
8146
+ }
8147
+ function localDay(at) {
8148
+ const d = new Date(at);
8149
+ const m = String(d.getMonth() + 1).padStart(2, "0");
8150
+ const day = String(d.getDate()).padStart(2, "0");
8151
+ return `${d.getFullYear()}-${m}-${day}`;
8152
+ }
8153
+ function computeCost(entry) {
8154
+ const price = PRICING.find((p) => p.match.test(entry.model));
8155
+ if (!price) return 0;
8156
+ return (entry.input * price.input + entry.output * price.output + entry.cacheWrite5m * price.input * 1.25 + entry.cacheWrite1h * price.input * 2 + entry.cacheRead * price.input * 0.1) / 1e6;
8157
+ }
8158
+ function computeProjection(points, now) {
8159
+ if (points.length === 0) return null;
8160
+ points.sort((a, b) => a.at - b.at);
8161
+ const blocks = [];
8162
+ let cur = null;
8163
+ for (const p of points) {
8164
+ if (!cur || p.at >= cur.start + BLOCK_MS || p.at - cur.lastAt >= BLOCK_MS) {
8165
+ cur = { start: Math.floor(p.at / HOUR_MS) * HOUR_MS, firstAt: p.at, lastAt: p.at, tokens: 0 };
8166
+ blocks.push(cur);
8167
+ }
8168
+ cur.lastAt = p.at;
8169
+ cur.tokens += p.tokens;
8170
+ }
8171
+ const last = blocks[blocks.length - 1];
8172
+ if (now < last.start || now >= last.start + BLOCK_MS) return null;
8173
+ const blockEndAt = last.start + BLOCK_MS;
8174
+ let maxBlockTokens = 0;
8175
+ for (const b of blocks) {
8176
+ if (b !== last && b.tokens > maxBlockTokens) maxBlockTokens = b.tokens;
8177
+ }
8178
+ const elapsedMin = Math.max(1, (now - last.firstAt) / 6e4);
8179
+ const tokensPerMin = last.tokens / elapsedMin;
8180
+ let runsOutAt = null;
8181
+ if (maxBlockTokens > 0) {
8182
+ if (last.tokens >= maxBlockTokens) {
8183
+ runsOutAt = now;
8184
+ } else if (tokensPerMin > 0) {
8185
+ const eta = now + (maxBlockTokens - last.tokens) / tokensPerMin * 6e4;
8186
+ if (eta < blockEndAt) runsOutAt = eta;
8187
+ }
8188
+ }
8189
+ return {
8190
+ blockStartAt: last.start,
8191
+ blockEndAt,
8192
+ blockTokens: last.tokens,
8193
+ maxBlockTokens,
8194
+ tokensPerMin,
8195
+ runsOutAt
8196
+ };
8197
+ }
8198
+ function parseFile(path2) {
8199
+ let raw;
8200
+ try {
8201
+ raw = fs.readFileSync(path2, "utf8");
8202
+ } catch {
8203
+ return [];
8204
+ }
8205
+ const entries = [];
8206
+ for (const line of raw.split("\n")) {
8207
+ if (!line.includes('"assistant"') || !line.includes('"usage"')) continue;
8208
+ let obj;
8209
+ try {
8210
+ obj = JSON.parse(line);
8211
+ } catch {
8212
+ continue;
8213
+ }
8214
+ if (obj.type !== "assistant") continue;
8215
+ const message = obj.message;
8216
+ const usage = message?.usage;
8217
+ const model = typeof message?.model === "string" ? message.model : "";
8218
+ if (!usage || !model || model === "<synthetic>") continue;
8219
+ const num = (v) => typeof v === "number" && isFinite(v) ? v : 0;
8220
+ const cacheCreation = usage.cache_creation;
8221
+ const totalWrite = num(usage.cache_creation_input_tokens);
8222
+ const write1h = num(cacheCreation?.ephemeral_1h_input_tokens);
8223
+ const write5m = cacheCreation ? num(cacheCreation.ephemeral_5m_input_tokens) : totalWrite;
8224
+ const at = Date.parse(typeof obj.timestamp === "string" ? obj.timestamp : "") || 0;
8225
+ const msgId = typeof message?.id === "string" ? message.id : obj.uuid ?? "";
8226
+ const entry = {
8227
+ key: `${msgId}:${typeof obj.requestId === "string" ? obj.requestId : ""}`,
8228
+ day: localDay(at),
8229
+ at,
8230
+ model,
8231
+ input: num(usage.input_tokens),
8232
+ output: num(usage.output_tokens),
8233
+ cacheWrite5m: write5m,
8234
+ cacheWrite1h: write1h,
8235
+ cacheRead: num(usage.cache_read_input_tokens),
8236
+ costUSD: 0
8237
+ };
8238
+ const precomputed = num(obj.costUSD);
8239
+ entry.costUSD = precomputed > 0 ? precomputed : computeCost(entry);
8240
+ entries.push(entry);
8241
+ }
8242
+ return entries;
8243
+ }
8244
+ class UsageService {
8245
+ fileCache = /* @__PURE__ */ new Map();
8246
+ snapshot() {
8247
+ const now = Date.now();
8248
+ const today = localDay(now);
8249
+ const monthPrefix = today.slice(0, 7);
8250
+ const total = zeroTotals();
8251
+ const todayTotals = zeroTotals();
8252
+ const month = zeroTotals();
8253
+ const perModel = /* @__PURE__ */ new Map();
8254
+ const perProject = [];
8255
+ const seen = /* @__PURE__ */ new Set();
8256
+ const liveFiles = /* @__PURE__ */ new Set();
8257
+ const points = [];
8258
+ let projectDirs = [];
8259
+ try {
8260
+ if (fs.existsSync(PROJECTS_DIR)) projectDirs = fs.readdirSync(PROJECTS_DIR);
8261
+ } catch {
8262
+ projectDirs = [];
8263
+ }
8264
+ for (const dir of projectDirs) {
8265
+ const dirPath = path.join(PROJECTS_DIR, dir);
8266
+ let files;
8267
+ try {
8268
+ files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".jsonl"));
8269
+ } catch {
8270
+ continue;
8271
+ }
8272
+ const project = {
8273
+ dir,
8274
+ total: zeroTotals(),
8275
+ today: zeroTotals(),
8276
+ lastActivityAt: 0
8277
+ };
8278
+ for (const file of files) {
8279
+ const path$1 = path.join(dirPath, file);
8280
+ liveFiles.add(path$1);
8281
+ for (const entry of this.entriesFor(path$1)) {
8282
+ if (seen.has(entry.key)) continue;
8283
+ seen.add(entry.key);
8284
+ if (entry.at > 0) {
8285
+ points.push({
8286
+ at: entry.at,
8287
+ tokens: entry.input + entry.output + entry.cacheWrite5m + entry.cacheWrite1h + entry.cacheRead
8288
+ });
8289
+ }
8290
+ add(total, entry);
8291
+ add(project.total, entry);
8292
+ if (entry.day === today) {
8293
+ add(todayTotals, entry);
8294
+ add(project.today, entry);
8295
+ }
8296
+ if (entry.day.startsWith(monthPrefix)) add(month, entry);
8297
+ let m = perModel.get(entry.model);
8298
+ if (!m) perModel.set(entry.model, m = zeroTotals());
8299
+ add(m, entry);
8300
+ if (entry.at > project.lastActivityAt) project.lastActivityAt = entry.at;
8301
+ }
8302
+ }
8303
+ if (project.total.costUSD > 0 || project.total.outputTokens > 0) perProject.push(project);
8304
+ }
8305
+ for (const path2 of [...this.fileCache.keys()]) {
8306
+ if (!liveFiles.has(path2)) this.fileCache.delete(path2);
8307
+ }
8308
+ perProject.sort((a, b) => b.total.costUSD - a.total.costUSD);
8309
+ return {
8310
+ total,
8311
+ today: todayTotals,
8312
+ month,
8313
+ perProject,
8314
+ perModel: [...perModel.entries()].map(([model, totals]) => ({ model, totals })).sort((a, b) => b.totals.costUSD - a.totals.costUSD),
8315
+ projection: computeProjection(points, now),
8316
+ updatedAt: now
8317
+ };
8318
+ }
8319
+ entriesFor(path2) {
8320
+ let stat;
8321
+ try {
8322
+ stat = fs.statSync(path2);
8323
+ } catch {
8324
+ this.fileCache.delete(path2);
8325
+ return [];
8326
+ }
8327
+ const cached = this.fileCache.get(path2);
8328
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
8329
+ return cached.entries;
8330
+ }
8331
+ const entries = parseFile(path2);
8332
+ this.fileCache.set(path2, { mtimeMs: stat.mtimeMs, size: stat.size, entries });
8333
+ return entries;
8334
+ }
8335
+ }
7576
8336
  let win = null;
7577
8337
  const getWin = () => win;
7578
8338
  const persistence = new Persistence();
@@ -7654,7 +8414,11 @@ if (!gotLock) {
7654
8414
  const conductor = new ConductorService(persistence, sessions, features, autoExpand, getWin);
7655
8415
  const factory = new FactoryService(getWin, emitGame);
7656
8416
  const agentRegistry = new AgentRegistryService(persistence, getWin);
7657
- const gamification = new GamificationService(getWin);
8417
+ const usage = new UsageService();
8418
+ const gamification = new GamificationService(getWin, () => {
8419
+ const t = usage.snapshot().total;
8420
+ return t.inputTokens + t.outputTokens;
8421
+ });
7658
8422
  gameBus.on("game", (e) => gamification.award(e));
7659
8423
  registerIpc(
7660
8424
  sessions,
@@ -7668,6 +8432,7 @@ if (!gotLock) {
7668
8432
  agentRegistry,
7669
8433
  tokenEff,
7670
8434
  gamification,
8435
+ usage,
7671
8436
  getWin
7672
8437
  );
7673
8438
  createWindow();
@@ -7677,6 +8442,7 @@ if (!gotLock) {
7677
8442
  autoExpand.start();
7678
8443
  tokenEff.start();
7679
8444
  factory.start();
8445
+ gamification.start();
7680
8446
  conductor.onTurnComplete((messages) => {
7681
8447
  factory.considerConversation(messages);
7682
8448
  emitGame({ type: "conductor.turn" });