claude-maestro 0.1.20 → 0.1.21

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 +708 -226
  2. package/out/preload/index.js +4 -0
  3. package/out/renderer/assets/{index-C2xG1-2F.js → index-BDJGybQo.js} +2 -2
  4. package/out/renderer/assets/{index-CALj_g-h.js → index-BDxfkk76.js} +2 -2
  5. package/out/renderer/assets/{index-BX4eMUiW.js → index-BNIPHMhg.js} +2 -2
  6. package/out/renderer/assets/{index-BSnEdUx8.js → index-BPiKmKfX.js} +5 -5
  7. package/out/renderer/assets/{index-BUr9qTcP.js → index-Bf87-cF0.js} +5 -5
  8. package/out/renderer/assets/{index--LsdCcT2.js → index-Bm6BMufC.js} +2 -2
  9. package/out/renderer/assets/{index-KjJOaoK3.js → index-Bw6wDwNB.js} +2 -2
  10. package/out/renderer/assets/{index-CwrdXZxY.js → index-C2wfbMG1.js} +2 -2
  11. package/out/renderer/assets/{index-BD5wVgZG.js → index-C3_9l5Zo.js} +2 -2
  12. package/out/renderer/assets/{index-2Q5NJLLa.js → index-CIAp39oC.js} +2 -2
  13. package/out/renderer/assets/{index-B4pxYlCv.css → index-CTyPr1hG.css} +942 -102
  14. package/out/renderer/assets/{index-B9wQ40iJ.js → index-CYo0nynQ.js} +4 -4
  15. package/out/renderer/assets/{index-BdkQGOfF.js → index-CZmL3oq-.js} +5 -5
  16. package/out/renderer/assets/{index-CqEbh7gN.js → index-CdG7xnB7.js} +2 -2
  17. package/out/renderer/assets/{index-Dpj9EP42.js → index-Cfvyl_8T.js} +3 -3
  18. package/out/renderer/assets/{index-NXP0WfIH.js → index-Ck4WZgFA.js} +2 -2
  19. package/out/renderer/assets/{index-DFnn9t0U.js → index-D9lxbtli.js} +5 -5
  20. package/out/renderer/assets/{index-DMLpIsZn.js → index-DD6EoqPp.js} +2 -2
  21. package/out/renderer/assets/{index-BKVqTAbd.js → index-DGNONcNh.js} +3 -3
  22. package/out/renderer/assets/{index-K7jPnu8A.js → index-DroXAl3A.js} +1 -1
  23. package/out/renderer/assets/{index-BYrBOYyo.js → index-DzjUOrFM.js} +5 -5
  24. package/out/renderer/assets/{index-BeXyvqqV.js → index-Uh6FxvAQ.js} +4358 -2417
  25. package/out/renderer/assets/{index-DwCelZNB.js → index-iIulTEbp.js} +5 -5
  26. package/out/renderer/assets/{index-RU5VUPJx.js → index-kJ0KF5bI.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
  }
@@ -859,6 +901,33 @@ async function listCheckpoints(folder) {
859
901
  }
860
902
  return out;
861
903
  }
904
+ async function checkpointDiff(folder, id) {
905
+ const empty = { diff: "", binary: false, truncated: false };
906
+ if (!isValidCheckpointId(id)) return empty;
907
+ const root2 = await repoRootOf(folder);
908
+ const ref = CHECKPOINT_REF_PREFIX + id;
909
+ const resolved = await git(root2, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
910
+ if (resolved.code !== 0 || !resolved.stdout.trim()) return empty;
911
+ const hash = resolved.stdout.trim();
912
+ const indexFile = path.join(os.tmpdir(), `maestro-ckpt-diff-${id}-${checkpointSeq++}.index`);
913
+ try {
914
+ const indexEnv = { GIT_INDEX_FILE: indexFile };
915
+ const add2 = await git(root2, ["add", "-A"], indexEnv);
916
+ if (add2.code !== 0) return empty;
917
+ const writeTree = await git(root2, ["write-tree"], indexEnv);
918
+ if (writeTree.code !== 0) return empty;
919
+ const curTree = writeTree.stdout.trim();
920
+ const res = await git(root2, ["diff", hash, curTree]);
921
+ return toFileDiff(res.stdout);
922
+ } catch {
923
+ return empty;
924
+ } finally {
925
+ try {
926
+ fs.rmSync(indexFile, { force: true });
927
+ } catch {
928
+ }
929
+ }
930
+ }
862
931
  async function restoreCheckpoint(folder, id) {
863
932
  if (!isValidCheckpointId(id)) {
864
933
  return { ok: false, output: `Invalid checkpoint id: ${id}`, safety: null };
@@ -939,6 +1008,10 @@ class StatusDetector {
939
1008
  get lastOutput() {
940
1009
  return this.lastOutputAt;
941
1010
  }
1011
+ /** ANSI-stripped tail of recent output, for prompt-content checks. */
1012
+ get recentText() {
1013
+ return stripAnsi(this.tail);
1014
+ }
942
1015
  /** ms epoch when the current status was continuously entered (watchdog clock). */
943
1016
  get since() {
944
1017
  return this.statusSince;
@@ -2497,7 +2570,7 @@ const MIN_CONFIDENCE = 0.6;
2497
2570
  const MAX_SUGGESTIONS_PER_DETECT = 2;
2498
2571
  const JUDGE_DAILY_CAP = 12;
2499
2572
  const MAX_SUGGESTIONS = 60;
2500
- function localDay$1() {
2573
+ function localDay$2() {
2501
2574
  const d = /* @__PURE__ */ new Date();
2502
2575
  return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
2503
2576
  }
@@ -2679,7 +2752,7 @@ class FactoryService {
2679
2752
  return;
2680
2753
  }
2681
2754
  const turnsAtStart = g.turnsSinceDetect;
2682
- const day = localDay$1();
2755
+ const day = localDay$2();
2683
2756
  const callsToday = g.judgeDay === day ? g.judgeCallsToday : 0;
2684
2757
  if (callsToday >= JUDGE_DAILY_CAP) return;
2685
2758
  const recent = messages.filter((m) => !m.pending && m.text?.trim()).slice(-6);
@@ -2705,7 +2778,7 @@ class FactoryService {
2705
2778
  this.inFlight = null;
2706
2779
  this.setBusy(false);
2707
2780
  const g2 = this.store.loadGrowth();
2708
- const day2 = localDay$1();
2781
+ const day2 = localDay$2();
2709
2782
  this.store.setGrowth({
2710
2783
  ...g2,
2711
2784
  lastDetectAt: Date.now(),
@@ -3858,7 +3931,10 @@ class FsService {
3858
3931
  }
3859
3932
  }
3860
3933
  }
3934
+ const TOKENS_PER_XP = 1e3;
3861
3935
  const XP_TABLE = {
3936
+ "tokens.burn": 0,
3937
+ // computed from meta.tokens in xpForEvent
3862
3938
  "session.turn": 5,
3863
3939
  "conductor.turn": 5,
3864
3940
  "action.run": 8,
@@ -3875,6 +3951,9 @@ const XP_TABLE = {
3875
3951
  "feature.merge": 80
3876
3952
  };
3877
3953
  function xpForEvent(e) {
3954
+ if (e.type === "tokens.burn") {
3955
+ return Math.max(0, Math.floor((e.meta?.tokens ?? 0) / TOKENS_PER_XP));
3956
+ }
3878
3957
  let xp = XP_TABLE[e.type] ?? 0;
3879
3958
  if (e.type === "worktree.merge") xp += Math.min(e.meta?.commits ?? 0, 10) * 3;
3880
3959
  return xp;
@@ -3903,6 +3982,8 @@ const DEFAULT_GAME_STATE = {
3903
3982
  counters: {},
3904
3983
  nightTurns: 0,
3905
3984
  earlyTurns: 0,
3985
+ tokensBurned: 0,
3986
+ usageTokensSeen: -1,
3906
3987
  createdAt: 0
3907
3988
  };
3908
3989
  const c = (ctx, t) => ctx.counters[t] ?? 0;
@@ -3930,6 +4011,23 @@ const ACHIEVEMENTS = [
3930
4011
  { id: "first-agent", title: "Agent Architect", desc: "Create your first agent.", icon: "🤖", category: "factory", predicate: (x) => c(x, "factory.agent") >= 1 },
3931
4012
  { id: "toolsmith", title: "Toolsmith", desc: "Create 5 skills/agents.", icon: "⚒", category: "factory", predicate: (x) => c(x, "factory.skill") + c(x, "factory.agent") >= 5 },
3932
4013
  { 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 },
4014
+ // workflow (checkpoints, actions, feature specs, auto-expand)
4015
+ { id: "first-checkpoint", title: "Safe Keeper", desc: "Make your first checkpoint.", icon: "📍", category: "workflow", predicate: (x) => c(x, "checkpoint.create") >= 1 },
4016
+ { id: "checkpoint-25", title: "Time Traveler", desc: "Make 25 checkpoints.", icon: "⏳", category: "workflow", predicate: (x) => c(x, "checkpoint.create") >= 25 },
4017
+ { id: "first-action", title: "Press Start", desc: "Run your first reusable action.", icon: "▶️", category: "workflow", predicate: (x) => c(x, "action.run") >= 1 },
4018
+ { id: "action-50", title: "Power User", desc: "Run 50 actions.", icon: "⚡", category: "workflow", predicate: (x) => c(x, "action.run") >= 50 },
4019
+ { id: "first-feature-spec", title: "Drawing Board", desc: "Save your first feature spec.", icon: "📝", category: "workflow", predicate: (x) => c(x, "feature.save") >= 1 },
4020
+ { id: "feature-architect", title: "Spec Architect", desc: "Save 10 feature specs.", icon: "📐", category: "workflow", predicate: (x) => c(x, "feature.save") >= 10 },
4021
+ { id: "first-autoexpand", title: "Brainstormer", desc: "Finish your first Auto-Expand run.", icon: "💡", category: "workflow", predicate: (x) => c(x, "autoexpand.done") >= 1 },
4022
+ { id: "autoexpand-10", title: "Idea Machine", desc: "Finish 10 Auto-Expand runs.", icon: "🧠", category: "workflow", predicate: (x) => c(x, "autoexpand.done") >= 10 },
4023
+ // guardian (Sentinels)
4024
+ { id: "first-sentinel", title: "On Watch", desc: "Run your first Sentinel check.", icon: "🛡", category: "guardian", predicate: (x) => c(x, "sentinel.run") >= 1 },
4025
+ { id: "sentinel-25", title: "Vigilant", desc: "Run 25 Sentinel checks.", icon: "👁", category: "guardian", predicate: (x) => c(x, "sentinel.run") >= 25 },
4026
+ { id: "sentinel-100", title: "Guardian", desc: "Run 100 Sentinel checks.", icon: "🦾", category: "guardian", predicate: (x) => c(x, "sentinel.run") >= 100 },
4027
+ // tokens (burning tokens levels you up)
4028
+ { id: "tokens-1m", title: "Token Tinkerer", desc: "Burn 1M tokens.", icon: "🪙", category: "tokens", predicate: (x) => x.tokensBurned >= 1e6 },
4029
+ { id: "tokens-10m", title: "Token Furnace", desc: "Burn 10M tokens.", icon: "🔥", category: "tokens", predicate: (x) => x.tokensBurned >= 1e7 },
4030
+ { id: "tokens-100m", title: "Token Inferno", desc: "Burn 100M tokens.", icon: "🌋", category: "tokens", predicate: (x) => x.tokensBurned >= 1e8 },
3933
4031
  // streak
3934
4032
  { id: "streak-3", title: "Warmed Up", desc: "Keep a 3-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 3 },
3935
4033
  { id: "streak-7", title: "On Fire", desc: "Keep a 7-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 7 },
@@ -3939,7 +4037,11 @@ const ACHIEVEMENTS = [
3939
4037
  { id: "early-bird", title: "Early Bird", desc: "Finish a turn between 5 and 8am.", icon: "🌅", category: "time", predicate: (x) => x.earlyTurns >= 1 },
3940
4038
  // level
3941
4039
  { 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 }
4040
+ { id: "level-25", title: "Maestro Prime", desc: "Reach level 25.", icon: "🌟", category: "level", predicate: (x) => x.level >= 25 },
4041
+ { id: "level-50", title: "Maestro Legend", desc: "Reach level 50.", icon: "👑", category: "level", predicate: (x) => x.level >= 50 },
4042
+ // mastery (cross-cutting capstones)
4043
+ { 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 },
4044
+ { 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
4045
  ];
3944
4046
  const DAILY_QUEST_POOL = [
3945
4047
  { id: "q-turns-5", title: "Finish 5 Claude turns", target: 5, reward: 30, events: ["session.turn"] },
@@ -3951,7 +4053,11 @@ const DAILY_QUEST_POOL = [
3951
4053
  { id: "q-conductor-3", title: "Have 3 Conductor turns", target: 3, reward: 30, events: ["conductor.turn"] },
3952
4054
  { id: "q-factory-1", title: "Create a skill or agent", target: 1, reward: 75, events: ["factory.skill", "factory.agent"] },
3953
4055
  { 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"] }
4056
+ { id: "q-pr-1", title: "Open a pull request", target: 1, reward: 50, events: ["worktree.pr"] },
4057
+ { id: "q-action-3", title: "Run 3 reusable actions", target: 3, reward: 30, events: ["action.run"] },
4058
+ { id: "q-sentinel-1", title: "Run a Sentinel check", target: 1, reward: 25, events: ["sentinel.run"] },
4059
+ { id: "q-autoexpand-1", title: "Finish an Auto-Expand run", target: 1, reward: 40, events: ["autoexpand.done"] },
4060
+ { id: "q-checkpoint-3", title: "Make 3 checkpoints", target: 3, reward: 40, events: ["checkpoint.create"] }
3955
4061
  ];
3956
4062
  const questDef = (id) => DAILY_QUEST_POOL.find((q) => q.id === id);
3957
4063
  function hashStr(s) {
@@ -4017,6 +4123,8 @@ class GamificationStore {
4017
4123
  }
4018
4124
  s.nightTurns = num(s.nightTurns, 0);
4019
4125
  s.earlyTurns = num(s.earlyTurns, 0);
4126
+ s.tokensBurned = num(s.tokensBurned, 0);
4127
+ s.usageTokensSeen = num(s.usageTokensSeen, -1);
4020
4128
  s.xp = num(s.xp, 0);
4021
4129
  if (!s.createdAt) s.createdAt = Date.now();
4022
4130
  this.state = s;
@@ -4053,20 +4161,66 @@ class GamificationStore {
4053
4161
  }
4054
4162
  }
4055
4163
  const ACHIEVEMENT_XP = 25;
4164
+ const TOKEN_POLL_MS = 6e4;
4056
4165
  class GamificationService {
4057
- constructor(getWin2) {
4166
+ /**
4167
+ * @param getUsageTokens returns the lifetime input+output token total (from
4168
+ * UsageService). Polled to award XP for tokens burned since the last check.
4169
+ */
4170
+ constructor(getWin2, getUsageTokens) {
4058
4171
  this.getWin = getWin2;
4172
+ this.getUsageTokens = getUsageTokens;
4059
4173
  this.state = this.store.load();
4060
4174
  }
4061
4175
  store = new GamificationStore();
4062
4176
  state;
4177
+ tokenTimer = null;
4178
+ /** Begin polling burned tokens (baselined on the first tick, so historical
4179
+ * usage is never retroactively dumped into XP). */
4180
+ start() {
4181
+ if (this.tokenTimer || !this.getUsageTokens) return;
4182
+ this.pollTokenBurn();
4183
+ this.tokenTimer = setInterval(() => this.pollTokenBurn(), TOKEN_POLL_MS);
4184
+ }
4063
4185
  /** GameState + derived level fields (for the initial renderer fetch). */
4064
4186
  snapshot() {
4065
4187
  return { ...this.state, ...levelInfo(this.state.xp) };
4066
4188
  }
4067
4189
  dispose() {
4190
+ if (this.tokenTimer) {
4191
+ clearInterval(this.tokenTimer);
4192
+ this.tokenTimer = null;
4193
+ }
4068
4194
  this.store.saveNow();
4069
4195
  }
4196
+ /**
4197
+ * Reconcile burned tokens into XP. The first observation only records the
4198
+ * baseline (no award). Afterward, each whole `TOKENS_PER_XP` of new input+
4199
+ * output tokens grants 1 XP via a `tokens.burn` event; the sub-unit remainder
4200
+ * is carried (the baseline advances only by tokens actually converted), so
4201
+ * even light usage eventually counts. A drop in the total (pruned transcripts)
4202
+ * silently re-baselines.
4203
+ */
4204
+ pollTokenBurn() {
4205
+ if (!this.getUsageTokens) return;
4206
+ try {
4207
+ const total = this.getUsageTokens();
4208
+ if (!Number.isFinite(total) || total < 0) return;
4209
+ const seen = this.state.usageTokensSeen;
4210
+ if (seen < 0 || total < seen) {
4211
+ this.state.usageTokensSeen = total;
4212
+ this.store.set(this.state);
4213
+ return;
4214
+ }
4215
+ const units = Math.floor((total - seen) / TOKENS_PER_XP);
4216
+ if (units <= 0) return;
4217
+ const consumed = units * TOKENS_PER_XP;
4218
+ this.state.usageTokensSeen = seen + consumed;
4219
+ this.award({ type: "tokens.burn", meta: { tokens: consumed } });
4220
+ } catch (err) {
4221
+ console.error("Token-burn poll failed (ignored):", err);
4222
+ }
4223
+ }
4070
4224
  /**
4071
4225
  * Apply one event: bump counters, roll the day (streak + quests), add XP,
4072
4226
  * advance quests, unlock achievements — each guarded so nothing double-counts.
@@ -4092,7 +4246,11 @@ class GamificationService {
4092
4246
  });
4093
4247
  }
4094
4248
  }
4095
- s.counters[e.type] = (s.counters[e.type] ?? 0) + 1;
4249
+ if (e.type === "tokens.burn") {
4250
+ s.tokensBurned += Math.max(0, e.meta?.tokens ?? 0);
4251
+ } else {
4252
+ s.counters[e.type] = (s.counters[e.type] ?? 0) + 1;
4253
+ }
4096
4254
  if (e.type === "session.turn" || e.type === "conductor.turn") {
4097
4255
  const hour = e.meta?.hour ?? now.getHours();
4098
4256
  if (hour >= 0 && hour < 5) s.nightTurns += 1;
@@ -4125,7 +4283,8 @@ class GamificationService {
4125
4283
  nightTurns: s.nightTurns,
4126
4284
  earlyTurns: s.earlyTurns,
4127
4285
  streakLongest: s.streak.longest,
4128
- level: levelForXp(s.xp)
4286
+ level: levelForXp(s.xp),
4287
+ tokensBurned: s.tokensBurned
4129
4288
  };
4130
4289
  for (const a of ACHIEVEMENTS) {
4131
4290
  if (s.achievements[a.id]) continue;
@@ -4281,6 +4440,182 @@ async function clearBackgroundImage() {
4281
4440
  } catch {
4282
4441
  }
4283
4442
  }
4443
+ const PROJECTS_DIR$1 = path.join(os.homedir(), ".claude", "projects");
4444
+ const PREVIEW_MAX_CHARS = 160;
4445
+ function encodeFolder(folder) {
4446
+ return folder.replace(/[^a-zA-Z0-9]/g, "-");
4447
+ }
4448
+ function messageText(message) {
4449
+ if (!message || typeof message !== "object") return null;
4450
+ const content = message.content;
4451
+ if (typeof content === "string") return content.trim() || null;
4452
+ if (Array.isArray(content)) {
4453
+ const parts = [];
4454
+ for (const block of content) {
4455
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
4456
+ parts.push(block.text);
4457
+ }
4458
+ }
4459
+ const joined = parts.join(" ").trim();
4460
+ return joined || null;
4461
+ }
4462
+ return null;
4463
+ }
4464
+ function toPreview(text) {
4465
+ const oneLine = text.replace(/\s+/g, " ").trim();
4466
+ return oneLine.length > PREVIEW_MAX_CHARS ? oneLine.slice(0, PREVIEW_MAX_CHARS) + "…" : oneLine;
4467
+ }
4468
+ function summarizeFile(path2, id) {
4469
+ let raw;
4470
+ try {
4471
+ raw = fs.readFileSync(path2, "utf8");
4472
+ } catch {
4473
+ return null;
4474
+ }
4475
+ let messageCount = 0;
4476
+ let lastActivityAt = 0;
4477
+ let preview = "";
4478
+ for (const line of raw.split("\n")) {
4479
+ if (!line.trim()) continue;
4480
+ let obj;
4481
+ try {
4482
+ obj = JSON.parse(line);
4483
+ } catch {
4484
+ continue;
4485
+ }
4486
+ const type = obj.type;
4487
+ if (type !== "user" && type !== "assistant") continue;
4488
+ messageCount++;
4489
+ const at = Date.parse(typeof obj.timestamp === "string" ? obj.timestamp : "");
4490
+ if (Number.isFinite(at) && at > lastActivityAt) lastActivityAt = at;
4491
+ if (!preview && type === "user" && obj.isMeta !== true) {
4492
+ const text = messageText(obj.message);
4493
+ if (text) preview = toPreview(text);
4494
+ }
4495
+ }
4496
+ return { id, lastActivityAt, messageCount, preview };
4497
+ }
4498
+ function listConversations(folder) {
4499
+ const dir = path.join(PROJECTS_DIR$1, encodeFolder(folder));
4500
+ if (!fs.existsSync(dir)) return [];
4501
+ let files;
4502
+ try {
4503
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
4504
+ } catch {
4505
+ return [];
4506
+ }
4507
+ const out = [];
4508
+ for (const file of files) {
4509
+ const summary = summarizeFile(path.join(dir, file), file.slice(0, -".jsonl".length));
4510
+ if (summary) out.push(summary);
4511
+ }
4512
+ out.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
4513
+ return out;
4514
+ }
4515
+ const SEARCH_MIN_QUERY = 2;
4516
+ const SEARCH_MAX_PER_CONVERSATION = 50;
4517
+ const SEARCH_MAX_RESULTS = 100;
4518
+ const SNIPPET_BEFORE = 40;
4519
+ const SNIPPET_AFTER = 140;
4520
+ const searchCache = /* @__PURE__ */ new Map();
4521
+ function makeSnippet(text, matchIndex, queryLength) {
4522
+ const start = Math.max(0, matchIndex - SNIPPET_BEFORE);
4523
+ const end = Math.min(text.length, matchIndex + queryLength + SNIPPET_AFTER);
4524
+ const core = text.slice(start, end).replace(/\s+/g, " ").trim();
4525
+ return (start > 0 ? "… " : "") + core + (end < text.length ? " …" : "");
4526
+ }
4527
+ function parseForSearch(path2, id) {
4528
+ const conv = { id, cwd: "", lastActivityAt: 0, messages: [] };
4529
+ let raw;
4530
+ try {
4531
+ raw = fs.readFileSync(path2, "utf8");
4532
+ } catch {
4533
+ return conv;
4534
+ }
4535
+ for (const line of raw.split("\n")) {
4536
+ if (!line.includes('"user"') && !line.includes('"assistant"')) continue;
4537
+ let obj;
4538
+ try {
4539
+ obj = JSON.parse(line);
4540
+ } catch {
4541
+ continue;
4542
+ }
4543
+ if (obj.type !== "user" && obj.type !== "assistant") continue;
4544
+ const text = messageText(obj.message);
4545
+ if (text) conv.messages.push(text);
4546
+ if (typeof obj.cwd === "string" && obj.cwd) conv.cwd = obj.cwd;
4547
+ const at = Date.parse(typeof obj.timestamp === "string" ? obj.timestamp : "");
4548
+ if (Number.isFinite(at) && at > conv.lastActivityAt) conv.lastActivityAt = at;
4549
+ }
4550
+ return conv;
4551
+ }
4552
+ function searchableFor(path2, id) {
4553
+ let stat;
4554
+ try {
4555
+ stat = fs.statSync(path2);
4556
+ } catch {
4557
+ searchCache.delete(path2);
4558
+ return { id, cwd: "", lastActivityAt: 0, messages: [] };
4559
+ }
4560
+ const cached = searchCache.get(path2);
4561
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.conv;
4562
+ const conv = parseForSearch(path2, id);
4563
+ searchCache.set(path2, { mtimeMs: stat.mtimeMs, size: stat.size, conv });
4564
+ return conv;
4565
+ }
4566
+ function searchConversations(query) {
4567
+ const q = query.trim();
4568
+ if (q.length < SEARCH_MIN_QUERY) return [];
4569
+ const needle = q.toLowerCase();
4570
+ let projectDirs = [];
4571
+ try {
4572
+ if (fs.existsSync(PROJECTS_DIR$1)) projectDirs = fs.readdirSync(PROJECTS_DIR$1);
4573
+ } catch {
4574
+ return [];
4575
+ }
4576
+ const hits = [];
4577
+ const liveFiles = /* @__PURE__ */ new Set();
4578
+ for (const dir of projectDirs) {
4579
+ const dirPath = path.join(PROJECTS_DIR$1, dir);
4580
+ let files;
4581
+ try {
4582
+ files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".jsonl"));
4583
+ } catch {
4584
+ continue;
4585
+ }
4586
+ for (const file of files) {
4587
+ const path$1 = path.join(dirPath, file);
4588
+ liveFiles.add(path$1);
4589
+ const conv = searchableFor(path$1, file.slice(0, -".jsonl".length));
4590
+ let matchCount = 0;
4591
+ let snippet = null;
4592
+ for (const text of conv.messages) {
4593
+ const lower = text.toLowerCase();
4594
+ let idx = lower.indexOf(needle);
4595
+ while (idx !== -1) {
4596
+ if (!snippet) snippet = makeSnippet(text, idx, q.length);
4597
+ if (++matchCount >= SEARCH_MAX_PER_CONVERSATION) break;
4598
+ idx = lower.indexOf(needle, idx + needle.length);
4599
+ }
4600
+ if (matchCount >= SEARCH_MAX_PER_CONVERSATION) break;
4601
+ }
4602
+ if (matchCount > 0 && snippet) {
4603
+ hits.push({
4604
+ conversationId: conv.id,
4605
+ cwd: conv.cwd,
4606
+ lastActivityAt: conv.lastActivityAt,
4607
+ matchCount,
4608
+ snippet
4609
+ });
4610
+ }
4611
+ }
4612
+ }
4613
+ for (const path2 of [...searchCache.keys()]) {
4614
+ if (!liveFiles.has(path2)) searchCache.delete(path2);
4615
+ }
4616
+ hits.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
4617
+ return hits.slice(0, SEARCH_MAX_RESULTS);
4618
+ }
4284
4619
  const CREDENTIALS_PATH = path.join(os.homedir(), ".claude", ".credentials.json");
4285
4620
  const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
4286
4621
  const OAUTH_BETA = "oauth-2025-04-20";
@@ -4357,216 +4692,74 @@ class UsageLimitsService {
4357
4692
  };
4358
4693
  }
4359
4694
  }
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) {
4695
+ function localDay$1(at) {
4383
4696
  const d = new Date(at);
4384
4697
  const m = String(d.getMonth() + 1).padStart(2, "0");
4385
4698
  const day = String(d.getDate()).padStart(2, "0");
4386
4699
  return `${d.getFullYear()}-${m}-${day}`;
4387
4700
  }
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;
4701
+ class BudgetAlerts {
4702
+ constructor(getWin2) {
4703
+ this.getWin = getWin2;
4405
4704
  }
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;
4705
+ daily = { period: "", fired: /* @__PURE__ */ new Set() };
4706
+ monthly = { period: "", fired: /* @__PURE__ */ new Set() };
4707
+ /** Re-evaluate both budgets against the latest snapshot; fire any new alerts. */
4708
+ check(snap, settings) {
4709
+ const threshold = clampThreshold(settings.budgetAlertThreshold);
4710
+ const day = localDay$1(snap.updatedAt);
4711
+ this.checkBudget(this.daily, "daily", day, snap.today.costUSD, settings.dailyBudgetUSD, threshold);
4712
+ this.checkBudget(
4713
+ this.monthly,
4714
+ "monthly",
4715
+ day.slice(0, 7),
4716
+ snap.month.costUSD,
4717
+ settings.monthlyBudgetUSD,
4718
+ threshold
4719
+ );
4412
4720
  }
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;
4721
+ checkBudget(state, scope, period, spend, budget, thresholdPct) {
4722
+ if (budget == null || budget <= 0) return;
4723
+ if (state.period !== period) {
4724
+ state.period = period;
4725
+ state.fired.clear();
4726
+ }
4727
+ if (spend <= 0) return;
4728
+ const thresholdValue = budget * thresholdPct / 100;
4729
+ if (spend >= budget) {
4730
+ if (!state.fired.has("limit")) {
4731
+ this.fire(scope, "limit", spend, budget);
4732
+ state.fired.add("limit");
4733
+ state.fired.add("threshold");
4734
+ }
4735
+ } else if (spend >= thresholdValue) {
4736
+ if (!state.fired.has("threshold")) {
4737
+ this.fire(scope, "threshold", spend, budget);
4738
+ state.fired.add("threshold");
4739
+ }
4422
4740
  }
4423
4741
  }
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 [];
4439
- }
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);
4742
+ fire(scope, level, spend, budget) {
4743
+ const win2 = this.getWin();
4744
+ const pct = Math.round(spend / budget * 100);
4745
+ const label = scope === "daily" ? "Daily" : "Monthly";
4746
+ const title = level === "limit" ? `${label} spend budget reached` : `${label} spend budget alert`;
4747
+ const body = `Spent ${fmtUSD(spend)} of your ${fmtUSD(budget)} ${scope} budget (${pct}%).`;
4748
+ if (!(win2?.isFocused() ?? false)) win2?.flashFrame(true);
4749
+ const notification = new electron.Notification({ title, body });
4750
+ notification.on("click", () => {
4751
+ win2?.show();
4752
+ win2?.focus();
4753
+ });
4754
+ notification.show();
4476
4755
  }
4477
- return entries;
4478
4756
  }
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
- }
4757
+ function clampThreshold(pct) {
4758
+ if (typeof pct !== "number" || !isFinite(pct) || pct <= 0) return 80;
4759
+ return Math.min(100, pct);
4760
+ }
4761
+ function fmtUSD(v) {
4762
+ return `$${v.toFixed(2)}`;
4570
4763
  }
4571
4764
  const CONDUCTOR_ATTACH_SCOPE = "conductor";
4572
4765
  function tokenize(template) {
@@ -4576,7 +4769,7 @@ function tokenize(template) {
4576
4769
  while (m = re.exec(template)) tokens.push(m[1] ?? m[2]);
4577
4770
  return tokens;
4578
4771
  }
4579
- function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, gamification, getWin2) {
4772
+ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, gamification, usage, getWin2) {
4580
4773
  const rootOf = (id) => {
4581
4774
  const config = sessions.getConfig(id);
4582
4775
  if (!config) throw new Error(`Unknown session: ${id}`);
@@ -4634,6 +4827,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4634
4827
  (_e, sessionId, path2) => sessions.getGitFileDiff(sessionId, path2)
4635
4828
  );
4636
4829
  electron.ipcMain.handle("git:branches", (_e, sessionId) => sessions.listBranches(sessionId));
4830
+ electron.ipcMain.handle("git:branchDiff", (_e, sessionId) => sessions.getBranchDiff(sessionId));
4637
4831
  electron.ipcMain.handle(
4638
4832
  "checkpoint:create",
4639
4833
  (_e, sessionId, label) => sessions.createCheckpoint(sessionId, label)
@@ -4642,6 +4836,10 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4642
4836
  "checkpoint:list",
4643
4837
  (_e, sessionId) => sessions.listCheckpoints(sessionId)
4644
4838
  );
4839
+ electron.ipcMain.handle(
4840
+ "checkpoint:diff",
4841
+ (_e, sessionId, id) => sessions.getCheckpointDiff(sessionId, id)
4842
+ );
4645
4843
  electron.ipcMain.handle(
4646
4844
  "checkpoint:restore",
4647
4845
  (_e, sessionId, id) => sessions.restoreCheckpoint(sessionId, id)
@@ -4913,10 +5111,16 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4913
5111
  "tokenEff:detectTools",
4914
5112
  (_e, refresh) => tokenEff.detectTools(refresh ?? false)
4915
5113
  );
4916
- const usage = new UsageService();
4917
- electron.ipcMain.handle("usage:get", () => usage.snapshot());
5114
+ const budgetAlerts = new BudgetAlerts(getWin2);
5115
+ electron.ipcMain.handle("usage:get", () => {
5116
+ const snap = usage.snapshot();
5117
+ budgetAlerts.check(snap, persistence2.state.settings);
5118
+ return snap;
5119
+ });
4918
5120
  const usageLimits = new UsageLimitsService();
4919
5121
  electron.ipcMain.handle("usage:limits", () => usageLimits.limits());
5122
+ electron.ipcMain.handle("conversations:list", (_e, folder) => listConversations(folder));
5123
+ electron.ipcMain.handle("conversations:search", (_e, query) => searchConversations(query));
4920
5124
  electron.ipcMain.handle(
4921
5125
  "transcript:export",
4922
5126
  async (_e, sessionId, fileName, content) => {
@@ -4982,7 +5186,10 @@ const DEFAULT_SETTINGS = {
4982
5186
  accentColor: null,
4983
5187
  gamificationEnabled: true,
4984
5188
  gamificationReduceMotion: false,
4985
- gamificationSound: false
5189
+ gamificationSound: false,
5190
+ dailyBudgetUSD: null,
5191
+ monthlyBudgetUSD: null,
5192
+ budgetAlertThreshold: 80
4986
5193
  };
4987
5194
  const DEFAULT_CATEGORIES = [
4988
5195
  {
@@ -5587,6 +5794,8 @@ const ACTION_SHELL_READY_MS = 1200;
5587
5794
  const CLAUDE_SUBMIT_DELAY_MS = 300;
5588
5795
  const QUEUE_IDLE_DELAY_MS = 3e3;
5589
5796
  const AUTO_COMPLETE_IDLE_DELAY_MS = 9e4;
5797
+ const PLAN_APPROVAL_RE = /(?:auto-accept edits|keep planning|manually approve)/i;
5798
+ const PLAN_ACCEPT_DELAY_MS = 600;
5590
5799
  const WATCHDOG_TICK_MS = 5e3;
5591
5800
  const SCROLLBACK_DIVIDER = "\r\n\x1B[0m\x1B[2m── restored from previous session ──\x1B[0m\r\n\r\n";
5592
5801
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -5635,6 +5844,10 @@ class SessionManager {
5635
5844
  autoCompleteTimers = /* @__PURE__ */ new Map();
5636
5845
  /** Worktree session ids with an auto-complete action in flight (fires once). */
5637
5846
  autoCompleteInFlight = /* @__PURE__ */ new Set();
5847
+ /** Worktree session ids whose current plan-approval prompt Maestro has already
5848
+ * answered — re-armed when claude goes back to working, so each distinct plan
5849
+ * is auto-accepted exactly once. */
5850
+ planAccepted = /* @__PURE__ */ new Set();
5638
5851
  /** On-disk tail of each terminal's output, replayed on app restart. */
5639
5852
  scrollback = new ScrollbackStore();
5640
5853
  /**
@@ -5699,14 +5912,15 @@ class SessionManager {
5699
5912
  claudeArgs: [],
5700
5913
  startMode: "continue"
5701
5914
  };
5915
+ const terminals = opts?.terminals ?? [claudeTerminal];
5702
5916
  const config = {
5703
5917
  id: crypto.randomUUID(),
5704
5918
  name: opts?.name ?? path.basename(folder) ?? folder,
5705
5919
  folder,
5706
5920
  color: opts?.color ?? null,
5707
5921
  order: Math.max(0, ...this.state.sessions.map((s) => s.order + 1)),
5708
- terminals: opts?.terminals ?? [claudeTerminal],
5709
- activeTerminalId: claudeTerminal.id,
5922
+ terminals,
5923
+ activeTerminalId: terminals[0]?.id ?? null,
5710
5924
  expandedPaths: [],
5711
5925
  categoryId: opts?.categoryId ?? null
5712
5926
  };
@@ -5786,6 +6000,19 @@ class SessionManager {
5786
6000
  if (!config) return { branches: [], current: null, defaultBranch: null };
5787
6001
  return listBranches(config.folder);
5788
6002
  }
6003
+ /**
6004
+ * Cumulative diff of a worktree task's branch against its base branch (their
6005
+ * merge-base), for the read-only "Review changes" tab. Returns an empty diff
6006
+ * for a non-worktree session — the UI only offers this on worktree tasks.
6007
+ */
6008
+ async getBranchDiff(sessionId) {
6009
+ const config = this.getConfig(sessionId);
6010
+ const wt = config?.worktree;
6011
+ if (!config || !wt) {
6012
+ return { diff: "", truncated: false, files: [], branch: "", baseBranch: "" };
6013
+ }
6014
+ return branchDiff(config.folder, wt.branch, wt.baseBranch);
6015
+ }
5789
6016
  /**
5790
6017
  * Initialize a git repository in a session's folder (so a non-repo session can
5791
6018
  * host parallel tasks). Returns the resulting git facts; throws git's message
@@ -5816,6 +6043,12 @@ class SessionManager {
5816
6043
  if (!config) return [];
5817
6044
  return listCheckpoints(config.folder);
5818
6045
  }
6046
+ /** Read-only diff between a checkpoint and the current working tree. */
6047
+ async getCheckpointDiff(sessionId, id) {
6048
+ const config = this.getConfig(sessionId);
6049
+ if (!config) return { diff: "", binary: false, truncated: false };
6050
+ return checkpointDiff(config.folder, id);
6051
+ }
5819
6052
  /** Restore a session's working tree back to a checkpoint (guarded, reversible). */
5820
6053
  async restoreCheckpoint(sessionId, id) {
5821
6054
  const config = this.getConfig(sessionId);
@@ -5868,13 +6101,15 @@ class SessionManager {
5868
6101
  console.error("copyWorktreeIncludes failed", err);
5869
6102
  }
5870
6103
  }
6104
+ const claudeArgs = [];
6105
+ if (opts.plan) claudeArgs.push("--permission-mode", "plan");
6106
+ if (opts.model) claudeArgs.push("--model", opts.model);
5871
6107
  const claudeTerminal = {
5872
6108
  id: crypto.randomUUID(),
5873
6109
  kind: "claude",
5874
6110
  title: "claude",
5875
6111
  order: 0,
5876
- // A model picked for the task pins its claude via --model; absent = CLI default.
5877
- claudeArgs: opts.model ? ["--model", opts.model] : [],
6112
+ claudeArgs,
5878
6113
  startMode: "fresh"
5879
6114
  };
5880
6115
  const config = {
@@ -5895,7 +6130,9 @@ class SessionManager {
5895
6130
  // Default to direct merge; only persist a PR/auto choice when set, so
5896
6131
  // existing tasks and the common case stay exactly as before.
5897
6132
  ...opts.completion && opts.completion !== "merge" ? { completion: opts.completion } : {},
5898
- ...opts.autoComplete ? { autoComplete: true } : {}
6133
+ ...opts.autoComplete ? { autoComplete: true } : {},
6134
+ ...opts.plan ? { plan: true } : {},
6135
+ ...opts.plan && opts.autoAcceptPlan ? { autoAcceptPlan: true } : {}
5899
6136
  }
5900
6137
  };
5901
6138
  this.state.sessions.push(config);
@@ -6104,10 +6341,10 @@ ${commit.output}` };
6104
6341
  // ---------- auto-complete (auto-merge / auto-PR when claude finishes) --------
6105
6342
  /**
6106
6343
  * (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.
6344
+ * just settled (done/idle). Only arms once the task's claude has actually
6345
+ * worked (so the boot-time settle never counts), and never while a prompt
6346
+ * queue is still draining or an action is already in flight / done. The fire
6347
+ * handler re-checks everything, so a terminal that wakes mid-countdown is safe.
6111
6348
  */
6112
6349
  scheduleAutoComplete(sessionId) {
6113
6350
  const config = this.getConfig(sessionId);
@@ -6131,6 +6368,28 @@ ${commit.output}` };
6131
6368
  if (timer) clearTimeout(timer);
6132
6369
  this.autoCompleteTimers.delete(sessionId);
6133
6370
  }
6371
+ /**
6372
+ * Auto-approve the plan-mode prompt for an opted-in task. Runs once per
6373
+ * attention episode (StatusDetector doesn't re-emit a held status, so a single
6374
+ * timer per episode is enough). The regex is evaluated only AFTER a short
6375
+ * settle — the terminal bell that raises attention can land a beat before the
6376
+ * menu text finishes rendering — and only then, if the approval menu is
6377
+ * actually on screen and not already handled, Maestro presses Enter to take
6378
+ * the default option ("Yes, and auto-accept edits"). Non-plan attention
6379
+ * prompts simply don't match the regex and are left for the user.
6380
+ * `planAccepted` is cleared when claude resumes working, so a later plan (it
6381
+ * can re-enter plan mode) is accepted too.
6382
+ */
6383
+ maybeAutoAcceptPlan(sessionId, terminalId) {
6384
+ if (this.planAccepted.has(sessionId)) return;
6385
+ setTimeout(() => {
6386
+ const live = this.ptys.get(terminalId);
6387
+ if (!live?.alive || this.planAccepted.has(sessionId)) return;
6388
+ if (!PLAN_APPROVAL_RE.test(live.detector.recentText)) return;
6389
+ this.planAccepted.add(sessionId);
6390
+ live.write("\r");
6391
+ }, PLAN_ACCEPT_DELAY_MS);
6392
+ }
6134
6393
  /**
6135
6394
  * Run a task's chosen completion automatically. Re-checks the gates that may
6136
6395
  * have changed during the countdown (still a worktree task, claude alive and
@@ -6146,7 +6405,8 @@ ${commit.output}` };
6146
6405
  if (config.promptQueue?.length) return;
6147
6406
  const terminal = this.claudeTargetTerminal(config);
6148
6407
  const pty2 = terminal ? this.ptys.get(terminal.id) : null;
6149
- if (!pty2?.alive || pty2.detector.current !== "idle") return;
6408
+ const settled = pty2?.detector.current === "done" || pty2?.detector.current === "idle";
6409
+ if (!pty2?.alive || !settled) return;
6150
6410
  const dirty = await dirtyCount(config.folder) ?? 0;
6151
6411
  const ahead = await aheadCount(wt.baseFolder, wt.baseBranch, wt.branch) ?? 0;
6152
6412
  if (dirty === 0 && ahead === 0) return;
@@ -6264,6 +6524,7 @@ ${err.message}`
6264
6524
  this.tokenEff.clearApplied(sessionId);
6265
6525
  this.worktreeWorked.delete(sessionId);
6266
6526
  this.autoCompleteInFlight.delete(sessionId);
6527
+ this.planAccepted.delete(sessionId);
6267
6528
  this.state.sessions = this.state.sessions.filter((s) => s.id !== sessionId);
6268
6529
  if (this.state.activeSessionId === sessionId) this.state.activeSessionId = null;
6269
6530
  this.persistence.scheduleSave();
@@ -6634,9 +6895,13 @@ ${err.message}`
6634
6895
  if (status === "done" || status === "idle") this.scheduleQueueDispatch(config.id);
6635
6896
  if (config.worktree?.autoComplete) {
6636
6897
  if (status === "working") this.worktreeWorked.add(config.id);
6637
- if (status === "idle") this.scheduleAutoComplete(config.id);
6898
+ if (status === "done" || status === "idle") this.scheduleAutoComplete(config.id);
6638
6899
  else this.clearAutoCompleteTimer(config.id);
6639
6900
  }
6901
+ if (config.worktree?.autoAcceptPlan) {
6902
+ if (status === "working") this.planAccepted.delete(config.id);
6903
+ if (status === "needs-attention") this.maybeAutoAcceptPlan(config.id, terminalId);
6904
+ }
6640
6905
  if (status !== "needs-attention") return;
6641
6906
  const focused = win2?.isFocused() ?? false;
6642
6907
  const isActive = this.state.activeSessionId === config.id && config.activeTerminalId === terminalId;
@@ -7573,6 +7838,217 @@ class TokenEfficiencyService {
7573
7838
  }
7574
7839
  }
7575
7840
  }
7841
+ const PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
7842
+ const BLOCK_MS = 5 * 60 * 60 * 1e3;
7843
+ const HOUR_MS = 60 * 60 * 1e3;
7844
+ const PRICING = [
7845
+ { match: /fable/, input: 10, output: 50 },
7846
+ { match: /opus-4-[5-9]/, input: 5, output: 25 },
7847
+ { match: /opus/, input: 15, output: 75 },
7848
+ { match: /sonnet/, input: 3, output: 15 },
7849
+ { match: /haiku-4/, input: 1, output: 5 },
7850
+ { match: /3-5-haiku|haiku-3-5/, input: 0.8, output: 4 },
7851
+ { match: /haiku/, input: 0.25, output: 1.25 }
7852
+ ];
7853
+ function zeroTotals() {
7854
+ return { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, costUSD: 0 };
7855
+ }
7856
+ function add(t, e) {
7857
+ t.inputTokens += e.input;
7858
+ t.outputTokens += e.output;
7859
+ t.cacheWriteTokens += e.cacheWrite5m + e.cacheWrite1h;
7860
+ t.cacheReadTokens += e.cacheRead;
7861
+ t.costUSD += e.costUSD;
7862
+ }
7863
+ function localDay(at) {
7864
+ const d = new Date(at);
7865
+ const m = String(d.getMonth() + 1).padStart(2, "0");
7866
+ const day = String(d.getDate()).padStart(2, "0");
7867
+ return `${d.getFullYear()}-${m}-${day}`;
7868
+ }
7869
+ function computeCost(entry) {
7870
+ const price = PRICING.find((p) => p.match.test(entry.model));
7871
+ if (!price) return 0;
7872
+ 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;
7873
+ }
7874
+ function computeProjection(points, now) {
7875
+ if (points.length === 0) return null;
7876
+ points.sort((a, b) => a.at - b.at);
7877
+ const blocks = [];
7878
+ let cur = null;
7879
+ for (const p of points) {
7880
+ if (!cur || p.at >= cur.start + BLOCK_MS || p.at - cur.lastAt >= BLOCK_MS) {
7881
+ cur = { start: Math.floor(p.at / HOUR_MS) * HOUR_MS, firstAt: p.at, lastAt: p.at, tokens: 0 };
7882
+ blocks.push(cur);
7883
+ }
7884
+ cur.lastAt = p.at;
7885
+ cur.tokens += p.tokens;
7886
+ }
7887
+ const last = blocks[blocks.length - 1];
7888
+ if (now < last.start || now >= last.start + BLOCK_MS) return null;
7889
+ const blockEndAt = last.start + BLOCK_MS;
7890
+ let maxBlockTokens = 0;
7891
+ for (const b of blocks) {
7892
+ if (b !== last && b.tokens > maxBlockTokens) maxBlockTokens = b.tokens;
7893
+ }
7894
+ const elapsedMin = Math.max(1, (now - last.firstAt) / 6e4);
7895
+ const tokensPerMin = last.tokens / elapsedMin;
7896
+ let runsOutAt = null;
7897
+ if (maxBlockTokens > 0) {
7898
+ if (last.tokens >= maxBlockTokens) {
7899
+ runsOutAt = now;
7900
+ } else if (tokensPerMin > 0) {
7901
+ const eta = now + (maxBlockTokens - last.tokens) / tokensPerMin * 6e4;
7902
+ if (eta < blockEndAt) runsOutAt = eta;
7903
+ }
7904
+ }
7905
+ return {
7906
+ blockStartAt: last.start,
7907
+ blockEndAt,
7908
+ blockTokens: last.tokens,
7909
+ maxBlockTokens,
7910
+ tokensPerMin,
7911
+ runsOutAt
7912
+ };
7913
+ }
7914
+ function parseFile(path2) {
7915
+ let raw;
7916
+ try {
7917
+ raw = fs.readFileSync(path2, "utf8");
7918
+ } catch {
7919
+ return [];
7920
+ }
7921
+ const entries = [];
7922
+ for (const line of raw.split("\n")) {
7923
+ if (!line.includes('"assistant"') || !line.includes('"usage"')) continue;
7924
+ let obj;
7925
+ try {
7926
+ obj = JSON.parse(line);
7927
+ } catch {
7928
+ continue;
7929
+ }
7930
+ if (obj.type !== "assistant") continue;
7931
+ const message = obj.message;
7932
+ const usage = message?.usage;
7933
+ const model = typeof message?.model === "string" ? message.model : "";
7934
+ if (!usage || !model || model === "<synthetic>") continue;
7935
+ const num = (v) => typeof v === "number" && isFinite(v) ? v : 0;
7936
+ const cacheCreation = usage.cache_creation;
7937
+ const totalWrite = num(usage.cache_creation_input_tokens);
7938
+ const write1h = num(cacheCreation?.ephemeral_1h_input_tokens);
7939
+ const write5m = cacheCreation ? num(cacheCreation.ephemeral_5m_input_tokens) : totalWrite;
7940
+ const at = Date.parse(typeof obj.timestamp === "string" ? obj.timestamp : "") || 0;
7941
+ const msgId = typeof message?.id === "string" ? message.id : obj.uuid ?? "";
7942
+ const entry = {
7943
+ key: `${msgId}:${typeof obj.requestId === "string" ? obj.requestId : ""}`,
7944
+ day: localDay(at),
7945
+ at,
7946
+ model,
7947
+ input: num(usage.input_tokens),
7948
+ output: num(usage.output_tokens),
7949
+ cacheWrite5m: write5m,
7950
+ cacheWrite1h: write1h,
7951
+ cacheRead: num(usage.cache_read_input_tokens),
7952
+ costUSD: 0
7953
+ };
7954
+ const precomputed = num(obj.costUSD);
7955
+ entry.costUSD = precomputed > 0 ? precomputed : computeCost(entry);
7956
+ entries.push(entry);
7957
+ }
7958
+ return entries;
7959
+ }
7960
+ class UsageService {
7961
+ fileCache = /* @__PURE__ */ new Map();
7962
+ snapshot() {
7963
+ const now = Date.now();
7964
+ const today = localDay(now);
7965
+ const monthPrefix = today.slice(0, 7);
7966
+ const total = zeroTotals();
7967
+ const todayTotals = zeroTotals();
7968
+ const month = zeroTotals();
7969
+ const perModel = /* @__PURE__ */ new Map();
7970
+ const perProject = [];
7971
+ const seen = /* @__PURE__ */ new Set();
7972
+ const liveFiles = /* @__PURE__ */ new Set();
7973
+ const points = [];
7974
+ let projectDirs = [];
7975
+ try {
7976
+ if (fs.existsSync(PROJECTS_DIR)) projectDirs = fs.readdirSync(PROJECTS_DIR);
7977
+ } catch {
7978
+ projectDirs = [];
7979
+ }
7980
+ for (const dir of projectDirs) {
7981
+ const dirPath = path.join(PROJECTS_DIR, dir);
7982
+ let files;
7983
+ try {
7984
+ files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".jsonl"));
7985
+ } catch {
7986
+ continue;
7987
+ }
7988
+ const project = {
7989
+ dir,
7990
+ total: zeroTotals(),
7991
+ today: zeroTotals(),
7992
+ lastActivityAt: 0
7993
+ };
7994
+ for (const file of files) {
7995
+ const path$1 = path.join(dirPath, file);
7996
+ liveFiles.add(path$1);
7997
+ for (const entry of this.entriesFor(path$1)) {
7998
+ if (seen.has(entry.key)) continue;
7999
+ seen.add(entry.key);
8000
+ if (entry.at > 0) {
8001
+ points.push({
8002
+ at: entry.at,
8003
+ tokens: entry.input + entry.output + entry.cacheWrite5m + entry.cacheWrite1h + entry.cacheRead
8004
+ });
8005
+ }
8006
+ add(total, entry);
8007
+ add(project.total, entry);
8008
+ if (entry.day === today) {
8009
+ add(todayTotals, entry);
8010
+ add(project.today, entry);
8011
+ }
8012
+ if (entry.day.startsWith(monthPrefix)) add(month, entry);
8013
+ let m = perModel.get(entry.model);
8014
+ if (!m) perModel.set(entry.model, m = zeroTotals());
8015
+ add(m, entry);
8016
+ if (entry.at > project.lastActivityAt) project.lastActivityAt = entry.at;
8017
+ }
8018
+ }
8019
+ if (project.total.costUSD > 0 || project.total.outputTokens > 0) perProject.push(project);
8020
+ }
8021
+ for (const path2 of [...this.fileCache.keys()]) {
8022
+ if (!liveFiles.has(path2)) this.fileCache.delete(path2);
8023
+ }
8024
+ perProject.sort((a, b) => b.total.costUSD - a.total.costUSD);
8025
+ return {
8026
+ total,
8027
+ today: todayTotals,
8028
+ month,
8029
+ perProject,
8030
+ perModel: [...perModel.entries()].map(([model, totals]) => ({ model, totals })).sort((a, b) => b.totals.costUSD - a.totals.costUSD),
8031
+ projection: computeProjection(points, now),
8032
+ updatedAt: now
8033
+ };
8034
+ }
8035
+ entriesFor(path2) {
8036
+ let stat;
8037
+ try {
8038
+ stat = fs.statSync(path2);
8039
+ } catch {
8040
+ this.fileCache.delete(path2);
8041
+ return [];
8042
+ }
8043
+ const cached = this.fileCache.get(path2);
8044
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
8045
+ return cached.entries;
8046
+ }
8047
+ const entries = parseFile(path2);
8048
+ this.fileCache.set(path2, { mtimeMs: stat.mtimeMs, size: stat.size, entries });
8049
+ return entries;
8050
+ }
8051
+ }
7576
8052
  let win = null;
7577
8053
  const getWin = () => win;
7578
8054
  const persistence = new Persistence();
@@ -7654,7 +8130,11 @@ if (!gotLock) {
7654
8130
  const conductor = new ConductorService(persistence, sessions, features, autoExpand, getWin);
7655
8131
  const factory = new FactoryService(getWin, emitGame);
7656
8132
  const agentRegistry = new AgentRegistryService(persistence, getWin);
7657
- const gamification = new GamificationService(getWin);
8133
+ const usage = new UsageService();
8134
+ const gamification = new GamificationService(getWin, () => {
8135
+ const t = usage.snapshot().total;
8136
+ return t.inputTokens + t.outputTokens;
8137
+ });
7658
8138
  gameBus.on("game", (e) => gamification.award(e));
7659
8139
  registerIpc(
7660
8140
  sessions,
@@ -7668,6 +8148,7 @@ if (!gotLock) {
7668
8148
  agentRegistry,
7669
8149
  tokenEff,
7670
8150
  gamification,
8151
+ usage,
7671
8152
  getWin
7672
8153
  );
7673
8154
  createWindow();
@@ -7677,6 +8158,7 @@ if (!gotLock) {
7677
8158
  autoExpand.start();
7678
8159
  tokenEff.start();
7679
8160
  factory.start();
8161
+ gamification.start();
7680
8162
  conductor.onTurnComplete((messages) => {
7681
8163
  factory.considerConversation(messages);
7682
8164
  emitGame({ type: "conductor.turn" });