claude-maestro 0.1.19 → 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 +1119 -274
  2. package/out/preload/index.js +7 -0
  3. package/out/renderer/assets/{index-9AHdXE8U.js → index-BDJGybQo.js} +2 -2
  4. package/out/renderer/assets/{index-CVWvgy2Y.js → index-BDxfkk76.js} +2 -2
  5. package/out/renderer/assets/{index-C0rsWi9C.js → index-BNIPHMhg.js} +2 -2
  6. package/out/renderer/assets/{index-JMVyecfQ.js → index-BPiKmKfX.js} +5 -5
  7. package/out/renderer/assets/{index-C479DZmL.js → index-Bf87-cF0.js} +5 -5
  8. package/out/renderer/assets/{index-CNNAMsV1.js → index-Bm6BMufC.js} +2 -2
  9. package/out/renderer/assets/{index-LW-gCnC-.js → index-Bw6wDwNB.js} +2 -2
  10. package/out/renderer/assets/{index-1Z03T0zz.js → index-C2wfbMG1.js} +2 -2
  11. package/out/renderer/assets/{index-DI2ly48w.js → index-C3_9l5Zo.js} +2 -2
  12. package/out/renderer/assets/{index-Dhxn3JIv.js → index-CIAp39oC.js} +2 -2
  13. package/out/renderer/assets/{index-Dgaj6c_K.css → index-CTyPr1hG.css} +1384 -104
  14. package/out/renderer/assets/{index-B59uuZRU.js → index-CYo0nynQ.js} +4 -4
  15. package/out/renderer/assets/{index-D9GPva9-.js → index-CZmL3oq-.js} +5 -5
  16. package/out/renderer/assets/{index-Bg4ondS2.js → index-CdG7xnB7.js} +2 -2
  17. package/out/renderer/assets/{index-CoyUYEik.js → index-Cfvyl_8T.js} +3 -3
  18. package/out/renderer/assets/{index-DJwKAmOm.js → index-Ck4WZgFA.js} +2 -2
  19. package/out/renderer/assets/{index-jAA5WJm3.js → index-D9lxbtli.js} +5 -5
  20. package/out/renderer/assets/{index-CZP8wVw-.js → index-DD6EoqPp.js} +2 -2
  21. package/out/renderer/assets/{index-CWk6CwGd.js → index-DGNONcNh.js} +3 -3
  22. package/out/renderer/assets/{index-BkOzhsuz.js → index-DroXAl3A.js} +1 -1
  23. package/out/renderer/assets/{index-CuHjzw7d.js → index-DzjUOrFM.js} +5 -5
  24. package/out/renderer/assets/{index-CTxGDYbk.js → index-Uh6FxvAQ.js} +6157 -3651
  25. package/out/renderer/assets/{index-Cq5xQaOf.js → index-iIulTEbp.js} +5 -5
  26. package/out/renderer/assets/{index-CXeHg_Qc.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
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  const electron = require("electron");
3
+ const events = require("events");
3
4
  const path = require("path");
4
5
  const fs = require("fs");
5
6
  const os = require("os");
@@ -462,6 +463,12 @@ async function gitChangedFiles(folder) {
462
463
  }
463
464
  }
464
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
+ }
465
472
  async function gitFileDiff(folder, path2) {
466
473
  const empty = { diff: "", binary: false, truncated: false };
467
474
  try {
@@ -475,11 +482,47 @@ async function gitFileDiff(folder, path2) {
475
482
  } else {
476
483
  res = await git(root2, ["diff", "--no-index", "--", "/dev/null", path2]);
477
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]);
478
514
  const text = res.stdout;
479
- const binary = /^Binary files .* differ$/m.test(text);
480
- 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
+ }
481
518
  const cut = text.lastIndexOf("\n", MAX_DIFF_CHARS);
482
- 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
+ };
483
526
  } catch {
484
527
  return empty;
485
528
  }
@@ -858,6 +901,33 @@ async function listCheckpoints(folder) {
858
901
  }
859
902
  return out;
860
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
+ }
861
931
  async function restoreCheckpoint(folder, id) {
862
932
  if (!isValidCheckpointId(id)) {
863
933
  return { ok: false, output: `Invalid checkpoint id: ${id}`, safety: null };
@@ -938,6 +1008,10 @@ class StatusDetector {
938
1008
  get lastOutput() {
939
1009
  return this.lastOutputAt;
940
1010
  }
1011
+ /** ANSI-stripped tail of recent output, for prompt-content checks. */
1012
+ get recentText() {
1013
+ return stripAnsi(this.tail);
1014
+ }
941
1015
  /** ms epoch when the current status was continuously entered (watchdog clock). */
942
1016
  get since() {
943
1017
  return this.statusSince;
@@ -1009,8 +1083,8 @@ function resolveClaude() {
1009
1083
  if (!IS_WIN$1) {
1010
1084
  return candidates[0] ? { file: candidates[0], argsPrefix: [] } : null;
1011
1085
  }
1012
- const exe = candidates.find((c) => c.toLowerCase().endsWith(".exe"));
1013
- const cmd = candidates.find((c) => /\.(cmd|bat)$/i.test(c));
1086
+ const exe = candidates.find((c2) => c2.toLowerCase().endsWith(".exe"));
1087
+ const cmd = candidates.find((c2) => /\.(cmd|bat)$/i.test(c2));
1014
1088
  if (exe) return { file: exe, argsPrefix: [] };
1015
1089
  if (cmd) return { file: process.env.ComSpec ?? "cmd.exe", argsPrefix: ["/c", cmd] };
1016
1090
  return null;
@@ -1323,10 +1397,12 @@ const ALLOWED_TOOLS$2 = [
1323
1397
  "Bash(git rev-parse:*)"
1324
1398
  ];
1325
1399
  class AutoExpandService {
1326
- constructor(persistence2, features, getWin2) {
1400
+ constructor(persistence2, features, getWin2, emitGame = () => {
1401
+ }) {
1327
1402
  this.persistence = persistence2;
1328
1403
  this.features = features;
1329
1404
  this.getWin = getWin2;
1405
+ this.emitGame = emitGame;
1330
1406
  }
1331
1407
  timer = null;
1332
1408
  /** Next scheduled run (ms epoch) per session id. */
@@ -1523,6 +1599,7 @@ class AutoExpandService {
1523
1599
  run.finishedAt = Date.now();
1524
1600
  run.status = status;
1525
1601
  this.broadcast(run.sessionId);
1602
+ if (status === "done") this.emitGame({ type: "autoexpand.done" });
1526
1603
  }
1527
1604
  broadcast(sessionId) {
1528
1605
  this.getWin()?.webContents.send("autoexpand:runs", sessionId, this.listRuns(sessionId));
@@ -2021,9 +2098,9 @@ class ConductorService {
2021
2098
  const list = focusId ? this.sessions.list().filter((s) => s.config.id === focusId) : this.sessions.list();
2022
2099
  const sessions = await Promise.all(
2023
2100
  list.map(async (s) => {
2024
- const c = s.config;
2025
- const git2 = await this.sessions.getGitStatus(c.id).catch(() => null);
2026
- const feats = this.features.list(c.id).map((f) => ({
2101
+ const c2 = s.config;
2102
+ const git2 = await this.sessions.getGitStatus(c2.id).catch(() => null);
2103
+ const feats = this.features.list(c2.id).map((f) => ({
2027
2104
  id: f.id,
2028
2105
  title: f.title,
2029
2106
  status: f.status,
@@ -2031,19 +2108,19 @@ class ConductorService {
2031
2108
  openSpecs: f.specs.filter((sp) => !sp.done).length
2032
2109
  }));
2033
2110
  const entry = {
2034
- id: c.id,
2035
- name: c.name,
2036
- folder: c.folder,
2037
- isActive: c.id === activeId,
2111
+ id: c2.id,
2112
+ name: c2.name,
2113
+ folder: c2.folder,
2114
+ isActive: c2.id === activeId,
2038
2115
  status: s.status,
2039
- categoryId: c.categoryId ?? null,
2116
+ categoryId: c2.categoryId ?? null,
2040
2117
  terminals: s.terminals.map((t) => ({ kind: t.config.kind, status: t.status })),
2041
- worktree: c.worktree ? {
2042
- parentSessionId: c.worktree.parentSessionId,
2043
- branch: c.worktree.branch,
2044
- baseBranch: c.worktree.baseBranch
2118
+ worktree: c2.worktree ? {
2119
+ parentSessionId: c2.worktree.parentSessionId,
2120
+ branch: c2.worktree.branch,
2121
+ baseBranch: c2.worktree.baseBranch
2045
2122
  } : null,
2046
- autoExpand: c.autoExpand ? { enabled: c.autoExpand.enabled, branch: c.autoExpand.branch } : null,
2123
+ autoExpand: c2.autoExpand ? { enabled: c2.autoExpand.enabled, branch: c2.autoExpand.branch } : null,
2047
2124
  git: git2 ? {
2048
2125
  branch: git2.branch,
2049
2126
  ahead: git2.ahead,
@@ -2052,8 +2129,8 @@ class ConductorService {
2052
2129
  } : null,
2053
2130
  features: feats
2054
2131
  };
2055
- if (c.worktree) {
2056
- const task = await this.sessions.getWorktreeTaskState(c.id).catch(() => null);
2132
+ if (c2.worktree) {
2133
+ const task = await this.sessions.getWorktreeTaskState(c2.id).catch(() => null);
2057
2134
  if (task) {
2058
2135
  entry.task = {
2059
2136
  dirty: task.dirty,
@@ -2493,7 +2570,7 @@ const MIN_CONFIDENCE = 0.6;
2493
2570
  const MAX_SUGGESTIONS_PER_DETECT = 2;
2494
2571
  const JUDGE_DAILY_CAP = 12;
2495
2572
  const MAX_SUGGESTIONS = 60;
2496
- function localDay$1() {
2573
+ function localDay$2() {
2497
2574
  const d = /* @__PURE__ */ new Date();
2498
2575
  return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
2499
2576
  }
@@ -2504,8 +2581,10 @@ const KNOWN_LABELS = {
2504
2581
  github: "GitHub"
2505
2582
  };
2506
2583
  class FactoryService {
2507
- constructor(getWin2) {
2584
+ constructor(getWin2, emitGame = () => {
2585
+ }) {
2508
2586
  this.getWin = getWin2;
2587
+ this.emitGame = emitGame;
2509
2588
  this.state = this.store.load();
2510
2589
  this.runs = restoreRuns(this.store.loadRuns());
2511
2590
  for (const s of this.state.suggestions) {
@@ -2623,23 +2702,23 @@ class FactoryService {
2623
2702
  absorbScanSuggestions(run) {
2624
2703
  let newest = null;
2625
2704
  let added = 0;
2626
- for (const c of run.candidates) {
2627
- if (c.status !== "proposed") continue;
2628
- if (this.suggestionDuplicate(c.kind, c.name, c.description)) continue;
2705
+ for (const c2 of run.candidates) {
2706
+ if (c2.status !== "proposed") continue;
2707
+ if (this.suggestionDuplicate(c2.kind, c2.name, c2.description)) continue;
2629
2708
  newest = this.enqueueSuggestion({
2630
- suggestedKind: c.kind,
2631
- name: c.name,
2632
- title: c.description,
2633
- description: c.description,
2634
- rationale: c.rationale,
2709
+ suggestedKind: c2.kind,
2710
+ name: c2.name,
2711
+ title: c2.description,
2712
+ description: c2.description,
2713
+ rationale: c2.rationale,
2635
2714
  origin: "scan",
2636
2715
  sourceRef: run.source,
2637
2716
  sourceLabel: run.sourceLabel,
2638
2717
  source: run.source,
2639
2718
  context: run.summary,
2640
- topics: c.topics,
2641
- keywords: c.keywords,
2642
- existing: c.existing,
2719
+ topics: c2.topics,
2720
+ keywords: c2.keywords,
2721
+ existing: c2.existing,
2643
2722
  confidence: 0.8
2644
2723
  });
2645
2724
  added++;
@@ -2673,7 +2752,7 @@ class FactoryService {
2673
2752
  return;
2674
2753
  }
2675
2754
  const turnsAtStart = g.turnsSinceDetect;
2676
- const day = localDay$1();
2755
+ const day = localDay$2();
2677
2756
  const callsToday = g.judgeDay === day ? g.judgeCallsToday : 0;
2678
2757
  if (callsToday >= JUDGE_DAILY_CAP) return;
2679
2758
  const recent = messages.filter((m) => !m.pending && m.text?.trim()).slice(-6);
@@ -2699,7 +2778,7 @@ class FactoryService {
2699
2778
  this.inFlight = null;
2700
2779
  this.setBusy(false);
2701
2780
  const g2 = this.store.loadGrowth();
2702
- const day2 = localDay$1();
2781
+ const day2 = localDay$2();
2703
2782
  this.store.setGrowth({
2704
2783
  ...g2,
2705
2784
  lastDetectAt: Date.now(),
@@ -3262,7 +3341,7 @@ Lessons learned (respect these):
3262
3341
  /** Approve a candidate: author its file content and write it to ~/.claude. An errored candidate can be retried. */
3263
3342
  async approve(runId, candidateId) {
3264
3343
  const run = this.runs.find((r) => r.id === runId);
3265
- const candidate = run?.candidates.find((c) => c.id === candidateId);
3344
+ const candidate = run?.candidates.find((c2) => c2.id === candidateId);
3266
3345
  if (!run || !candidate || candidate.status !== "proposed" && candidate.status !== "error") return;
3267
3346
  if (this.busy) return;
3268
3347
  this.setBusy(true);
@@ -3316,14 +3395,14 @@ Lessons learned (respect these):
3316
3395
  async approveAll(runId) {
3317
3396
  const run = this.runs.find((r) => r.id === runId);
3318
3397
  if (!run) return;
3319
- for (const c of run.candidates) {
3320
- if (c.status === "proposed") await this.approve(runId, c.id);
3398
+ for (const c2 of run.candidates) {
3399
+ if (c2.status === "proposed") await this.approve(runId, c2.id);
3321
3400
  if (this.cancelRequested) break;
3322
3401
  }
3323
3402
  }
3324
3403
  reject(runId, candidateId) {
3325
3404
  const run = this.runs.find((r) => r.id === runId);
3326
- const candidate = run?.candidates.find((c) => c.id === candidateId);
3405
+ const candidate = run?.candidates.find((c2) => c2.id === candidateId);
3327
3406
  if (!candidate || candidate.status !== "proposed") return;
3328
3407
  candidate.status = "rejected";
3329
3408
  this.broadcastRuns();
@@ -3423,6 +3502,10 @@ Lessons learned (respect these):
3423
3502
  createdAt: now,
3424
3503
  updatedAt: now
3425
3504
  });
3505
+ this.emitGame({
3506
+ type: input.kind === "agent" ? "factory.agent" : "factory.skill",
3507
+ meta: { name: input.name }
3508
+ });
3426
3509
  }
3427
3510
  for (const a of this.state.artifacts) {
3428
3511
  if (related.includes(a.name) && !a.relatedArtifacts.includes(input.name)) {
@@ -3590,10 +3673,10 @@ function restoreRuns(runs) {
3590
3673
  run.finishedAt = run.finishedAt ?? Date.now();
3591
3674
  run.summary = run.summary || "Interrupted — the app was closed mid-scan.";
3592
3675
  }
3593
- for (const c of run.candidates) {
3594
- if (c.status === "authoring") {
3595
- c.status = "proposed";
3596
- c.result = void 0;
3676
+ for (const c2 of run.candidates) {
3677
+ if (c2.status === "authoring") {
3678
+ c2.status = "proposed";
3679
+ c2.result = void 0;
3597
3680
  }
3598
3681
  }
3599
3682
  }
@@ -3626,9 +3709,11 @@ Feature: ${feature.title}
3626
3709
  Read that spec file in full, then implement every spec it lists. Make your changes in this worktree and commit them as each part works. If any spec is ambiguous, ask before guessing.`;
3627
3710
  }
3628
3711
  class FeatureService {
3629
- constructor(persistence2, sessions) {
3712
+ constructor(persistence2, sessions, emitGame = () => {
3713
+ }) {
3630
3714
  this.persistence = persistence2;
3631
3715
  this.sessions = sessions;
3716
+ this.emitGame = emitGame;
3632
3717
  }
3633
3718
  get features() {
3634
3719
  return this.persistence.state.features;
@@ -3649,8 +3734,12 @@ class FeatureService {
3649
3734
  save(feature) {
3650
3735
  const list = this.features;
3651
3736
  const idx = list.findIndex((f) => f.id === feature.id);
3652
- if (idx >= 0) list[idx] = feature;
3653
- else list.push(feature);
3737
+ if (idx >= 0) {
3738
+ list[idx] = feature;
3739
+ } else {
3740
+ list.push(feature);
3741
+ this.emitGame({ type: "feature.save" });
3742
+ }
3654
3743
  this.persistence.scheduleSave();
3655
3744
  }
3656
3745
  delete(id) {
@@ -3842,6 +3931,396 @@ class FsService {
3842
3931
  }
3843
3932
  }
3844
3933
  }
3934
+ const TOKENS_PER_XP = 1e3;
3935
+ const XP_TABLE = {
3936
+ "tokens.burn": 0,
3937
+ // computed from meta.tokens in xpForEvent
3938
+ "session.turn": 5,
3939
+ "conductor.turn": 5,
3940
+ "action.run": 8,
3941
+ "checkpoint.create": 10,
3942
+ "sentinel.run": 12,
3943
+ "session.create": 20,
3944
+ "worktree.create": 25,
3945
+ "feature.save": 30,
3946
+ "autoexpand.done": 40,
3947
+ "worktree.pr": 50,
3948
+ "worktree.merge": 60,
3949
+ "factory.skill": 75,
3950
+ "factory.agent": 75,
3951
+ "feature.merge": 80
3952
+ };
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
+ }
3957
+ let xp = XP_TABLE[e.type] ?? 0;
3958
+ if (e.type === "worktree.merge") xp += Math.min(e.meta?.commits ?? 0, 10) * 3;
3959
+ return xp;
3960
+ }
3961
+ function xpForLevel(n) {
3962
+ if (n <= 1) return 0;
3963
+ const k = n - 1;
3964
+ return 50 * k * k + 50 * k;
3965
+ }
3966
+ function levelForXp(xp) {
3967
+ if (xp <= 0) return 1;
3968
+ return Math.max(1, Math.floor((-50 + Math.sqrt(2500 + 200 * xp)) / 100) + 1);
3969
+ }
3970
+ function levelInfo(xp) {
3971
+ const level = levelForXp(xp);
3972
+ const floor = xpForLevel(level);
3973
+ const next = xpForLevel(level + 1);
3974
+ return { level, xpIntoLevel: xp - floor, xpForNextLevel: next - floor };
3975
+ }
3976
+ const DEFAULT_GAME_STATE = {
3977
+ xp: 0,
3978
+ streak: { current: 0, longest: 0, lastDay: "" },
3979
+ achievements: {},
3980
+ todaysQuests: [],
3981
+ questDay: "",
3982
+ counters: {},
3983
+ nightTurns: 0,
3984
+ earlyTurns: 0,
3985
+ tokensBurned: 0,
3986
+ usageTokensSeen: -1,
3987
+ createdAt: 0
3988
+ };
3989
+ const c = (ctx, t) => ctx.counters[t] ?? 0;
3990
+ const ACHIEVEMENTS = [
3991
+ // sessions
3992
+ { id: "first-session", title: "Hello, Maestro", desc: "Start your first session.", icon: "🎬", category: "sessions", predicate: (x) => c(x, "session.create") >= 1 },
3993
+ { id: "ten-sessions", title: "Regular", desc: "Start 10 sessions.", icon: "📁", category: "sessions", predicate: (x) => c(x, "session.create") >= 10 },
3994
+ { id: "worktree-novice", title: "Branching Out", desc: "Create your first parallel task.", icon: "🌱", category: "sessions", predicate: (x) => c(x, "worktree.create") >= 1 },
3995
+ { id: "worktree-adept", title: "Multitasker", desc: "Create 10 parallel tasks.", icon: "🌳", category: "sessions", predicate: (x) => c(x, "worktree.create") >= 10 },
3996
+ // merges
3997
+ { id: "first-merge", title: "Merge One", desc: "Merge your first worktree.", icon: "🔀", category: "merges", predicate: (x) => c(x, "worktree.merge") >= 1 },
3998
+ { id: "ten-merges", title: "Merge Maestro", desc: "Merge 10 worktrees.", icon: "🧬", category: "merges", predicate: (x) => c(x, "worktree.merge") >= 10 },
3999
+ { id: "fifty-merges", title: "Merge Machine", desc: "Merge 50 worktrees.", icon: "⚙️", category: "merges", predicate: (x) => c(x, "worktree.merge") >= 50 },
4000
+ { id: "pr-opener", title: "Pull Request", desc: "Open your first PR.", icon: "📤", category: "merges", predicate: (x) => c(x, "worktree.pr") >= 1 },
4001
+ { id: "pr-prolific", title: "PR Prolific", desc: "Open 10 PRs.", icon: "🚀", category: "merges", predicate: (x) => c(x, "worktree.pr") >= 10 },
4002
+ { id: "feature-shipper", title: "Ship It", desc: "Merge a feature you specced.", icon: "📦", category: "merges", predicate: (x) => c(x, "feature.merge") >= 1 },
4003
+ // turns
4004
+ { id: "first-turn", title: "First Light", desc: "Finish your first Claude turn.", icon: "✶", category: "turns", predicate: (x) => c(x, "session.turn") >= 1 },
4005
+ { id: "hundred-turns", title: "Century", desc: "Finish 100 turns.", icon: "💯", category: "turns", predicate: (x) => c(x, "session.turn") >= 100 },
4006
+ { id: "thousand-turns", title: "Marathoner", desc: "Finish 1,000 turns.", icon: "🏃", category: "turns", predicate: (x) => c(x, "session.turn") >= 1e3 },
4007
+ { id: "conductor-curious", title: "Conductor Curious", desc: "Have your first Conductor turn.", icon: "✦", category: "turns", predicate: (x) => c(x, "conductor.turn") >= 1 },
4008
+ { id: "conductor-regular", title: "Maestro of Maestro", desc: "Have 50 Conductor turns.", icon: "🎼", category: "turns", predicate: (x) => c(x, "conductor.turn") >= 50 },
4009
+ // factory
4010
+ { id: "first-skill", title: "Skill Smith", desc: "Create your first skill.", icon: "🛠", category: "factory", predicate: (x) => c(x, "factory.skill") >= 1 },
4011
+ { id: "first-agent", title: "Agent Architect", desc: "Create your first agent.", icon: "🤖", category: "factory", predicate: (x) => c(x, "factory.agent") >= 1 },
4012
+ { id: "toolsmith", title: "Toolsmith", desc: "Create 5 skills/agents.", icon: "⚒", category: "factory", predicate: (x) => c(x, "factory.skill") + c(x, "factory.agent") >= 5 },
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 },
4031
+ // streak
4032
+ { id: "streak-3", title: "Warmed Up", desc: "Keep a 3-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 3 },
4033
+ { id: "streak-7", title: "On Fire", desc: "Keep a 7-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 7 },
4034
+ { id: "streak-30", title: "Unstoppable", desc: "Keep a 30-day streak.", icon: "☄️", category: "streak", predicate: (x) => x.streakLongest >= 30 },
4035
+ // time of day
4036
+ { id: "night-owl", title: "Night Owl", desc: "Finish a turn between midnight and 5am.", icon: "🦉", category: "time", predicate: (x) => x.nightTurns >= 1 },
4037
+ { id: "early-bird", title: "Early Bird", desc: "Finish a turn between 5 and 8am.", icon: "🌅", category: "time", predicate: (x) => x.earlyTurns >= 1 },
4038
+ // level
4039
+ { id: "level-10", title: "Double Digits", desc: "Reach level 10.", icon: "⭐", category: "level", predicate: (x) => x.level >= 10 },
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 }
4045
+ ];
4046
+ const DAILY_QUEST_POOL = [
4047
+ { id: "q-turns-5", title: "Finish 5 Claude turns", target: 5, reward: 30, events: ["session.turn"] },
4048
+ { id: "q-merge-1", title: "Merge a worktree", target: 1, reward: 40, events: ["worktree.merge"] },
4049
+ { id: "q-merge-3", title: "Merge 3 worktrees", target: 3, reward: 100, events: ["worktree.merge"] },
4050
+ { id: "q-create-1", title: "Start a session", target: 1, reward: 20, events: ["session.create"] },
4051
+ { id: "q-worktree-2", title: "Spin up 2 parallel tasks", target: 2, reward: 50, events: ["worktree.create"] },
4052
+ { id: "q-checkpoint", title: "Make a checkpoint", target: 1, reward: 20, events: ["checkpoint.create"] },
4053
+ { id: "q-conductor-3", title: "Have 3 Conductor turns", target: 3, reward: 30, events: ["conductor.turn"] },
4054
+ { id: "q-factory-1", title: "Create a skill or agent", target: 1, reward: 75, events: ["factory.skill", "factory.agent"] },
4055
+ { id: "q-feature-1", title: "Save a feature spec", target: 1, reward: 30, events: ["feature.save"] },
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"] }
4061
+ ];
4062
+ const questDef = (id) => DAILY_QUEST_POOL.find((q) => q.id === id);
4063
+ function hashStr(s) {
4064
+ let h = 2166136261;
4065
+ for (let i = 0; i < s.length; i++) {
4066
+ h ^= s.charCodeAt(i);
4067
+ h = Math.imul(h, 16777619);
4068
+ }
4069
+ return h >>> 0;
4070
+ }
4071
+ function mulberry32(seed) {
4072
+ let a = seed >>> 0;
4073
+ return () => {
4074
+ a = a + 1831565813 | 0;
4075
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
4076
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
4077
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
4078
+ };
4079
+ }
4080
+ function pickDailyQuests(dayKey2, n = 3) {
4081
+ const rnd = mulberry32(hashStr("quest:" + dayKey2));
4082
+ const pool = [...DAILY_QUEST_POOL];
4083
+ for (let i = pool.length - 1; i > 0; i--) {
4084
+ const j = Math.floor(rnd() * (i + 1));
4085
+ [pool[i], pool[j]] = [pool[j], pool[i]];
4086
+ }
4087
+ return pool.slice(0, Math.min(n, pool.length)).map((q) => ({
4088
+ id: q.id,
4089
+ target: q.target,
4090
+ progress: 0,
4091
+ rewarded: false
4092
+ }));
4093
+ }
4094
+ function dayKey(d) {
4095
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
4096
+ }
4097
+ function prevDayKey(d) {
4098
+ const y = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1);
4099
+ return dayKey(y);
4100
+ }
4101
+ class GamificationStore {
4102
+ file = path.join(electron.app.getPath("userData"), "gamification.json");
4103
+ timer = null;
4104
+ state = structuredClone(DEFAULT_GAME_STATE);
4105
+ /** Load saved progress (best-effort; defaults on any error, back-compat via spread). */
4106
+ load() {
4107
+ try {
4108
+ const raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
4109
+ const s = { ...structuredClone(DEFAULT_GAME_STATE), ...raw ?? {} };
4110
+ const num = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : Number.isFinite(Number(v)) ? Number(v) : fallback;
4111
+ if (!s.streak || typeof s.streak !== "object") s.streak = { current: 0, longest: 0, lastDay: "" };
4112
+ s.streak.current = num(s.streak.current, 0);
4113
+ s.streak.longest = num(s.streak.longest, 0);
4114
+ if (typeof s.streak.lastDay !== "string") s.streak.lastDay = "";
4115
+ if (!s.achievements || typeof s.achievements !== "object" || Array.isArray(s.achievements)) {
4116
+ s.achievements = {};
4117
+ }
4118
+ if (!Array.isArray(s.todaysQuests)) s.todaysQuests = [];
4119
+ s.todaysQuests = s.todaysQuests.filter((q) => q && typeof q.id === "string").map((q) => ({ id: q.id, target: num(q.target, 1), progress: num(q.progress, 0), rewarded: !!q.rewarded }));
4120
+ if (!s.counters || typeof s.counters !== "object" || Array.isArray(s.counters)) s.counters = {};
4121
+ for (const k of Object.keys(s.counters)) {
4122
+ s.counters[k] = num(s.counters[k], 0);
4123
+ }
4124
+ s.nightTurns = num(s.nightTurns, 0);
4125
+ s.earlyTurns = num(s.earlyTurns, 0);
4126
+ s.tokensBurned = num(s.tokensBurned, 0);
4127
+ s.usageTokensSeen = num(s.usageTokensSeen, -1);
4128
+ s.xp = num(s.xp, 0);
4129
+ if (!s.createdAt) s.createdAt = Date.now();
4130
+ this.state = s;
4131
+ } catch {
4132
+ this.state = structuredClone(DEFAULT_GAME_STATE);
4133
+ this.state.createdAt = Date.now();
4134
+ }
4135
+ return this.state;
4136
+ }
4137
+ get() {
4138
+ return this.state;
4139
+ }
4140
+ set(state) {
4141
+ this.state = state;
4142
+ this.scheduleSave();
4143
+ }
4144
+ scheduleSave() {
4145
+ if (this.timer) clearTimeout(this.timer);
4146
+ this.timer = setTimeout(() => this.saveNow(), 500);
4147
+ }
4148
+ saveNow() {
4149
+ if (this.timer) {
4150
+ clearTimeout(this.timer);
4151
+ this.timer = null;
4152
+ }
4153
+ try {
4154
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
4155
+ const tmp = this.file + ".tmp";
4156
+ fs.writeFileSync(tmp, JSON.stringify(this.state, null, 2), "utf8");
4157
+ fs.renameSync(tmp, this.file);
4158
+ } catch (err) {
4159
+ console.error("Failed to persist gamification state:", err);
4160
+ }
4161
+ }
4162
+ }
4163
+ const ACHIEVEMENT_XP = 25;
4164
+ const TOKEN_POLL_MS = 6e4;
4165
+ class GamificationService {
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) {
4171
+ this.getWin = getWin2;
4172
+ this.getUsageTokens = getUsageTokens;
4173
+ this.state = this.store.load();
4174
+ }
4175
+ store = new GamificationStore();
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
+ }
4185
+ /** GameState + derived level fields (for the initial renderer fetch). */
4186
+ snapshot() {
4187
+ return { ...this.state, ...levelInfo(this.state.xp) };
4188
+ }
4189
+ dispose() {
4190
+ if (this.tokenTimer) {
4191
+ clearInterval(this.tokenTimer);
4192
+ this.tokenTimer = null;
4193
+ }
4194
+ this.store.saveNow();
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
+ }
4224
+ /**
4225
+ * Apply one event: bump counters, roll the day (streak + quests), add XP,
4226
+ * advance quests, unlock achievements — each guarded so nothing double-counts.
4227
+ * Wrapped so a gamification failure can never break the caller's turn/merge.
4228
+ */
4229
+ award(e) {
4230
+ try {
4231
+ const s = this.state;
4232
+ const now = /* @__PURE__ */ new Date();
4233
+ const today = dayKey(now);
4234
+ const celebrations = [];
4235
+ if (s.streak.lastDay !== today) {
4236
+ s.streak.current = s.streak.lastDay === prevDayKey(now) ? s.streak.current + 1 : 1;
4237
+ s.streak.longest = Math.max(s.streak.longest, s.streak.current);
4238
+ s.streak.lastDay = today;
4239
+ s.todaysQuests = pickDailyQuests(today);
4240
+ s.questDay = today;
4241
+ if (s.streak.current >= 2) {
4242
+ celebrations.push({
4243
+ kind: "streak",
4244
+ seed: `streak:${today}:${s.streak.current}`,
4245
+ current: s.streak.current
4246
+ });
4247
+ }
4248
+ }
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
+ }
4254
+ if (e.type === "session.turn" || e.type === "conductor.turn") {
4255
+ const hour = e.meta?.hour ?? now.getHours();
4256
+ if (hour >= 0 && hour < 5) s.nightTurns += 1;
4257
+ else if (hour >= 5 && hour < 8) s.earlyTurns += 1;
4258
+ }
4259
+ const beforeLevel = levelForXp(s.xp);
4260
+ s.xp += xpForEvent(e);
4261
+ for (const q of s.todaysQuests) {
4262
+ if (q.rewarded) continue;
4263
+ const def = questDef(q.id);
4264
+ if (!def || !def.events.includes(e.type)) continue;
4265
+ q.progress = Math.min(q.target, q.progress + 1);
4266
+ if (q.progress >= q.target) {
4267
+ q.rewarded = true;
4268
+ s.xp += def.reward;
4269
+ celebrations.push({
4270
+ kind: "quest",
4271
+ seed: `quest:${today}:${q.id}`,
4272
+ id: q.id,
4273
+ title: def.title,
4274
+ xp: def.reward
4275
+ });
4276
+ }
4277
+ }
4278
+ let unlockedThisPass = true;
4279
+ while (unlockedThisPass) {
4280
+ unlockedThisPass = false;
4281
+ const ctx = {
4282
+ counters: s.counters,
4283
+ nightTurns: s.nightTurns,
4284
+ earlyTurns: s.earlyTurns,
4285
+ streakLongest: s.streak.longest,
4286
+ level: levelForXp(s.xp),
4287
+ tokensBurned: s.tokensBurned
4288
+ };
4289
+ for (const a of ACHIEVEMENTS) {
4290
+ if (s.achievements[a.id]) continue;
4291
+ if (a.predicate(ctx)) {
4292
+ s.achievements[a.id] = { unlockedAt: Date.now() };
4293
+ s.xp += ACHIEVEMENT_XP;
4294
+ unlockedThisPass = true;
4295
+ celebrations.push({
4296
+ kind: "achievement",
4297
+ seed: `ach:${a.id}`,
4298
+ id: a.id,
4299
+ title: a.title,
4300
+ icon: a.icon,
4301
+ xp: ACHIEVEMENT_XP
4302
+ });
4303
+ }
4304
+ }
4305
+ }
4306
+ const afterLevel = levelForXp(s.xp);
4307
+ for (let l = beforeLevel + 1; l <= afterLevel; l++) {
4308
+ celebrations.push({ kind: "level-up", seed: `level:${l}`, level: l });
4309
+ }
4310
+ this.store.set(s);
4311
+ this.broadcast();
4312
+ for (const c2 of celebrations) this.celebrate(c2);
4313
+ } catch (err) {
4314
+ console.error("Gamification award failed (ignored):", err);
4315
+ }
4316
+ }
4317
+ broadcast() {
4318
+ this.getWin()?.webContents.send("gamification:changed", this.snapshot());
4319
+ }
4320
+ celebrate(c2) {
4321
+ this.getWin()?.webContents.send("gamification:celebrate", c2);
4322
+ }
4323
+ }
3845
4324
  const IMAGE_EXTS$1 = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
3846
4325
  const THUMB_HEIGHT = 64;
3847
4326
  const MAX_LISTED = 100;
@@ -3961,6 +4440,182 @@ async function clearBackgroundImage() {
3961
4440
  } catch {
3962
4441
  }
3963
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
+ }
3964
4619
  const CREDENTIALS_PATH = path.join(os.homedir(), ".claude", ".credentials.json");
3965
4620
  const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
3966
4621
  const OAUTH_BETA = "oauth-2025-04-20";
@@ -4037,216 +4692,74 @@ class UsageLimitsService {
4037
4692
  };
4038
4693
  }
4039
4694
  }
4040
- const PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
4041
- const BLOCK_MS = 5 * 60 * 60 * 1e3;
4042
- const HOUR_MS = 60 * 60 * 1e3;
4043
- const PRICING = [
4044
- { match: /fable/, input: 10, output: 50 },
4045
- { match: /opus-4-[5-9]/, input: 5, output: 25 },
4046
- { match: /opus/, input: 15, output: 75 },
4047
- { match: /sonnet/, input: 3, output: 15 },
4048
- { match: /haiku-4/, input: 1, output: 5 },
4049
- { match: /3-5-haiku|haiku-3-5/, input: 0.8, output: 4 },
4050
- { match: /haiku/, input: 0.25, output: 1.25 }
4051
- ];
4052
- function zeroTotals() {
4053
- return { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, costUSD: 0 };
4054
- }
4055
- function add(t, e) {
4056
- t.inputTokens += e.input;
4057
- t.outputTokens += e.output;
4058
- t.cacheWriteTokens += e.cacheWrite5m + e.cacheWrite1h;
4059
- t.cacheReadTokens += e.cacheRead;
4060
- t.costUSD += e.costUSD;
4061
- }
4062
- function localDay(at) {
4695
+ function localDay$1(at) {
4063
4696
  const d = new Date(at);
4064
4697
  const m = String(d.getMonth() + 1).padStart(2, "0");
4065
4698
  const day = String(d.getDate()).padStart(2, "0");
4066
4699
  return `${d.getFullYear()}-${m}-${day}`;
4067
4700
  }
4068
- function computeCost(entry) {
4069
- const price = PRICING.find((p) => p.match.test(entry.model));
4070
- if (!price) return 0;
4071
- 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;
4072
- }
4073
- function computeProjection(points, now) {
4074
- if (points.length === 0) return null;
4075
- points.sort((a, b) => a.at - b.at);
4076
- const blocks = [];
4077
- let cur = null;
4078
- for (const p of points) {
4079
- if (!cur || p.at >= cur.start + BLOCK_MS || p.at - cur.lastAt >= BLOCK_MS) {
4080
- cur = { start: Math.floor(p.at / HOUR_MS) * HOUR_MS, firstAt: p.at, lastAt: p.at, tokens: 0 };
4081
- blocks.push(cur);
4082
- }
4083
- cur.lastAt = p.at;
4084
- cur.tokens += p.tokens;
4701
+ class BudgetAlerts {
4702
+ constructor(getWin2) {
4703
+ this.getWin = getWin2;
4085
4704
  }
4086
- const last = blocks[blocks.length - 1];
4087
- if (now < last.start || now >= last.start + BLOCK_MS) return null;
4088
- const blockEndAt = last.start + BLOCK_MS;
4089
- let maxBlockTokens = 0;
4090
- for (const b of blocks) {
4091
- 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
+ );
4092
4720
  }
4093
- const elapsedMin = Math.max(1, (now - last.firstAt) / 6e4);
4094
- const tokensPerMin = last.tokens / elapsedMin;
4095
- let runsOutAt = null;
4096
- if (maxBlockTokens > 0) {
4097
- if (last.tokens >= maxBlockTokens) {
4098
- runsOutAt = now;
4099
- } else if (tokensPerMin > 0) {
4100
- const eta = now + (maxBlockTokens - last.tokens) / tokensPerMin * 6e4;
4101
- 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
+ }
4102
4740
  }
4103
4741
  }
4104
- return {
4105
- blockStartAt: last.start,
4106
- blockEndAt,
4107
- blockTokens: last.tokens,
4108
- maxBlockTokens,
4109
- tokensPerMin,
4110
- runsOutAt
4111
- };
4112
- }
4113
- function parseFile(path2) {
4114
- let raw;
4115
- try {
4116
- raw = fs.readFileSync(path2, "utf8");
4117
- } catch {
4118
- return [];
4119
- }
4120
- const entries = [];
4121
- for (const line of raw.split("\n")) {
4122
- if (!line.includes('"assistant"') || !line.includes('"usage"')) continue;
4123
- let obj;
4124
- try {
4125
- obj = JSON.parse(line);
4126
- } catch {
4127
- continue;
4128
- }
4129
- if (obj.type !== "assistant") continue;
4130
- const message = obj.message;
4131
- const usage = message?.usage;
4132
- const model = typeof message?.model === "string" ? message.model : "";
4133
- if (!usage || !model || model === "<synthetic>") continue;
4134
- const num = (v) => typeof v === "number" && isFinite(v) ? v : 0;
4135
- const cacheCreation = usage.cache_creation;
4136
- const totalWrite = num(usage.cache_creation_input_tokens);
4137
- const write1h = num(cacheCreation?.ephemeral_1h_input_tokens);
4138
- const write5m = cacheCreation ? num(cacheCreation.ephemeral_5m_input_tokens) : totalWrite;
4139
- const at = Date.parse(typeof obj.timestamp === "string" ? obj.timestamp : "") || 0;
4140
- const msgId = typeof message?.id === "string" ? message.id : obj.uuid ?? "";
4141
- const entry = {
4142
- key: `${msgId}:${typeof obj.requestId === "string" ? obj.requestId : ""}`,
4143
- day: localDay(at),
4144
- at,
4145
- model,
4146
- input: num(usage.input_tokens),
4147
- output: num(usage.output_tokens),
4148
- cacheWrite5m: write5m,
4149
- cacheWrite1h: write1h,
4150
- cacheRead: num(usage.cache_read_input_tokens),
4151
- costUSD: 0
4152
- };
4153
- const precomputed = num(obj.costUSD);
4154
- entry.costUSD = precomputed > 0 ? precomputed : computeCost(entry);
4155
- 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();
4156
4755
  }
4157
- return entries;
4158
4756
  }
4159
- class UsageService {
4160
- fileCache = /* @__PURE__ */ new Map();
4161
- snapshot() {
4162
- const now = Date.now();
4163
- const today = localDay(now);
4164
- const monthPrefix = today.slice(0, 7);
4165
- const total = zeroTotals();
4166
- const todayTotals = zeroTotals();
4167
- const month = zeroTotals();
4168
- const perModel = /* @__PURE__ */ new Map();
4169
- const perProject = [];
4170
- const seen = /* @__PURE__ */ new Set();
4171
- const liveFiles = /* @__PURE__ */ new Set();
4172
- const points = [];
4173
- let projectDirs = [];
4174
- try {
4175
- if (fs.existsSync(PROJECTS_DIR)) projectDirs = fs.readdirSync(PROJECTS_DIR);
4176
- } catch {
4177
- projectDirs = [];
4178
- }
4179
- for (const dir of projectDirs) {
4180
- const dirPath = path.join(PROJECTS_DIR, dir);
4181
- let files;
4182
- try {
4183
- files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".jsonl"));
4184
- } catch {
4185
- continue;
4186
- }
4187
- const project = {
4188
- dir,
4189
- total: zeroTotals(),
4190
- today: zeroTotals(),
4191
- lastActivityAt: 0
4192
- };
4193
- for (const file of files) {
4194
- const path$1 = path.join(dirPath, file);
4195
- liveFiles.add(path$1);
4196
- for (const entry of this.entriesFor(path$1)) {
4197
- if (seen.has(entry.key)) continue;
4198
- seen.add(entry.key);
4199
- if (entry.at > 0) {
4200
- points.push({
4201
- at: entry.at,
4202
- tokens: entry.input + entry.output + entry.cacheWrite5m + entry.cacheWrite1h + entry.cacheRead
4203
- });
4204
- }
4205
- add(total, entry);
4206
- add(project.total, entry);
4207
- if (entry.day === today) {
4208
- add(todayTotals, entry);
4209
- add(project.today, entry);
4210
- }
4211
- if (entry.day.startsWith(monthPrefix)) add(month, entry);
4212
- let m = perModel.get(entry.model);
4213
- if (!m) perModel.set(entry.model, m = zeroTotals());
4214
- add(m, entry);
4215
- if (entry.at > project.lastActivityAt) project.lastActivityAt = entry.at;
4216
- }
4217
- }
4218
- if (project.total.costUSD > 0 || project.total.outputTokens > 0) perProject.push(project);
4219
- }
4220
- for (const path2 of [...this.fileCache.keys()]) {
4221
- if (!liveFiles.has(path2)) this.fileCache.delete(path2);
4222
- }
4223
- perProject.sort((a, b) => b.total.costUSD - a.total.costUSD);
4224
- return {
4225
- total,
4226
- today: todayTotals,
4227
- month,
4228
- perProject,
4229
- perModel: [...perModel.entries()].map(([model, totals]) => ({ model, totals })).sort((a, b) => b.totals.costUSD - a.totals.costUSD),
4230
- projection: computeProjection(points, now),
4231
- updatedAt: now
4232
- };
4233
- }
4234
- entriesFor(path2) {
4235
- let stat;
4236
- try {
4237
- stat = fs.statSync(path2);
4238
- } catch {
4239
- this.fileCache.delete(path2);
4240
- return [];
4241
- }
4242
- const cached = this.fileCache.get(path2);
4243
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
4244
- return cached.entries;
4245
- }
4246
- const entries = parseFile(path2);
4247
- this.fileCache.set(path2, { mtimeMs: stat.mtimeMs, size: stat.size, entries });
4248
- return entries;
4249
- }
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)}`;
4250
4763
  }
4251
4764
  const CONDUCTOR_ATTACH_SCOPE = "conductor";
4252
4765
  function tokenize(template) {
@@ -4256,7 +4769,7 @@ function tokenize(template) {
4256
4769
  while (m = re.exec(template)) tokens.push(m[1] ?? m[2]);
4257
4770
  return tokens;
4258
4771
  }
4259
- function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, getWin2) {
4772
+ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, gamification, usage, getWin2) {
4260
4773
  const rootOf = (id) => {
4261
4774
  const config = sessions.getConfig(id);
4262
4775
  if (!config) throw new Error(`Unknown session: ${id}`);
@@ -4314,6 +4827,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4314
4827
  (_e, sessionId, path2) => sessions.getGitFileDiff(sessionId, path2)
4315
4828
  );
4316
4829
  electron.ipcMain.handle("git:branches", (_e, sessionId) => sessions.listBranches(sessionId));
4830
+ electron.ipcMain.handle("git:branchDiff", (_e, sessionId) => sessions.getBranchDiff(sessionId));
4317
4831
  electron.ipcMain.handle(
4318
4832
  "checkpoint:create",
4319
4833
  (_e, sessionId, label) => sessions.createCheckpoint(sessionId, label)
@@ -4322,6 +4836,10 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4322
4836
  "checkpoint:list",
4323
4837
  (_e, sessionId) => sessions.listCheckpoints(sessionId)
4324
4838
  );
4839
+ electron.ipcMain.handle(
4840
+ "checkpoint:diff",
4841
+ (_e, sessionId, id) => sessions.getCheckpointDiff(sessionId, id)
4842
+ );
4325
4843
  electron.ipcMain.handle(
4326
4844
  "checkpoint:restore",
4327
4845
  (_e, sessionId, id) => sessions.restoreCheckpoint(sessionId, id)
@@ -4469,6 +4987,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4469
4987
  (_e, id, kind) => factory.createFromSuggestion(id, kind)
4470
4988
  );
4471
4989
  electron.ipcMain.handle("factory:dismissSuggestion", (_e, id) => factory.dismissSuggestion(id));
4990
+ electron.ipcMain.handle("gamification:get", () => gamification.snapshot());
4472
4991
  electron.ipcMain.handle("agents:get", () => agents.snapshot());
4473
4992
  electron.ipcMain.handle("agents:refresh", () => agents.refresh());
4474
4993
  electron.ipcMain.handle("agents:read", (_e, filePath) => agents.readAgentFile(filePath));
@@ -4592,10 +5111,16 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
4592
5111
  "tokenEff:detectTools",
4593
5112
  (_e, refresh) => tokenEff.detectTools(refresh ?? false)
4594
5113
  );
4595
- const usage = new UsageService();
4596
- 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
+ });
4597
5120
  const usageLimits = new UsageLimitsService();
4598
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));
4599
5124
  electron.ipcMain.handle(
4600
5125
  "transcript:export",
4601
5126
  async (_e, sessionId, fileName, content) => {
@@ -4656,7 +5181,15 @@ const DEFAULT_SETTINGS = {
4656
5181
  watchdogUnansweredMinutes: 5,
4657
5182
  tokenEfficiency: DEFAULT_TOKEN_EFFICIENCY,
4658
5183
  tokenEfficiencyRepoOverrides: {},
4659
- agentRegistryPath: "C:\\repos\\agent-factory\\registry\\registry.json"
5184
+ agentRegistryPath: "C:\\repos\\agent-factory\\registry\\registry.json",
5185
+ theme: "dark",
5186
+ accentColor: null,
5187
+ gamificationEnabled: true,
5188
+ gamificationReduceMotion: false,
5189
+ gamificationSound: false,
5190
+ dailyBudgetUSD: null,
5191
+ monthlyBudgetUSD: null,
5192
+ budgetAlertThreshold: 80
4660
5193
  };
4661
5194
  const DEFAULT_CATEGORIES = [
4662
5195
  {
@@ -4826,9 +5359,11 @@ function gitHead$1(folder) {
4826
5359
  });
4827
5360
  }
4828
5361
  class SentinelService {
4829
- constructor(persistence2, getWin2) {
5362
+ constructor(persistence2, getWin2, emitGame = () => {
5363
+ }) {
4830
5364
  this.persistence = persistence2;
4831
5365
  this.getWin = getWin2;
5366
+ this.emitGame = emitGame;
4832
5367
  }
4833
5368
  timer = null;
4834
5369
  /** Last observed HEAD per session id; baseline is set without firing. */
@@ -5008,6 +5543,7 @@ class SentinelService {
5008
5543
  run.summary = summary;
5009
5544
  run.findings = findings;
5010
5545
  this.broadcast(run.sessionId);
5546
+ if (status !== "error") this.emitGame({ type: "sentinel.run" });
5011
5547
  }
5012
5548
  broadcast(sessionId) {
5013
5549
  this.getWin()?.webContents.send("sentinel:runs", sessionId, this.listRuns(sessionId));
@@ -5258,6 +5794,8 @@ const ACTION_SHELL_READY_MS = 1200;
5258
5794
  const CLAUDE_SUBMIT_DELAY_MS = 300;
5259
5795
  const QUEUE_IDLE_DELAY_MS = 3e3;
5260
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;
5261
5799
  const WATCHDOG_TICK_MS = 5e3;
5262
5800
  const SCROLLBACK_DIVIDER = "\r\n\x1B[0m\x1B[2m── restored from previous session ──\x1B[0m\r\n\r\n";
5263
5801
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -5287,11 +5825,13 @@ const STATUS_PRIORITY = [
5287
5825
  "exited"
5288
5826
  ];
5289
5827
  class SessionManager {
5290
- constructor(persistence2, fs2, tokenEff, getWin2) {
5828
+ constructor(persistence2, fs2, tokenEff, getWin2, emitGame = () => {
5829
+ }) {
5291
5830
  this.persistence = persistence2;
5292
5831
  this.fs = fs2;
5293
5832
  this.tokenEff = tokenEff;
5294
5833
  this.getWin = getWin2;
5834
+ this.emitGame = emitGame;
5295
5835
  }
5296
5836
  /** Keyed by terminal id, across all sessions. */
5297
5837
  ptys = /* @__PURE__ */ new Map();
@@ -5304,6 +5844,10 @@ class SessionManager {
5304
5844
  autoCompleteTimers = /* @__PURE__ */ new Map();
5305
5845
  /** Worktree session ids with an auto-complete action in flight (fires once). */
5306
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();
5307
5851
  /** On-disk tail of each terminal's output, replayed on app restart. */
5308
5852
  scrollback = new ScrollbackStore();
5309
5853
  /**
@@ -5316,6 +5860,8 @@ class SessionManager {
5316
5860
  watchdog = /* @__PURE__ */ new Map();
5317
5861
  /** The watchdog interval; null until startWatchdog() runs. */
5318
5862
  watchdogTimer = null;
5863
+ /** Per-terminal last status, used only to award a turn on the working→done edge. */
5864
+ lastStatusForAward = /* @__PURE__ */ new Map();
5319
5865
  get state() {
5320
5866
  return this.persistence.state;
5321
5867
  }
@@ -5327,12 +5873,12 @@ class SessionManager {
5327
5873
  }
5328
5874
  categoryOf(config) {
5329
5875
  if (!config.categoryId) return null;
5330
- return this.state.categories.find((c) => c.id === config.categoryId) ?? null;
5876
+ return this.state.categories.find((c2) => c2.id === config.categoryId) ?? null;
5331
5877
  }
5332
5878
  /** Every MCP server name owned by any category — our materialization namespace. */
5333
5879
  managedServerNames() {
5334
5880
  const names = /* @__PURE__ */ new Set();
5335
- for (const c of this.state.categories) for (const s of c.mcpServers) names.add(s.name);
5881
+ for (const c2 of this.state.categories) for (const s of c2.mcpServers) names.add(s.name);
5336
5882
  return [...names];
5337
5883
  }
5338
5884
  /**
@@ -5366,14 +5912,15 @@ class SessionManager {
5366
5912
  claudeArgs: [],
5367
5913
  startMode: "continue"
5368
5914
  };
5915
+ const terminals = opts?.terminals ?? [claudeTerminal];
5369
5916
  const config = {
5370
5917
  id: crypto.randomUUID(),
5371
5918
  name: opts?.name ?? path.basename(folder) ?? folder,
5372
5919
  folder,
5373
5920
  color: opts?.color ?? null,
5374
5921
  order: Math.max(0, ...this.state.sessions.map((s) => s.order + 1)),
5375
- terminals: opts?.terminals ?? [claudeTerminal],
5376
- activeTerminalId: claudeTerminal.id,
5922
+ terminals,
5923
+ activeTerminalId: terminals[0]?.id ?? null,
5377
5924
  expandedPaths: [],
5378
5925
  categoryId: opts?.categoryId ?? null
5379
5926
  };
@@ -5383,6 +5930,7 @@ class SessionManager {
5383
5930
  for (const terminal of config.terminals) this.spawnTerminal(config, terminal, "fresh");
5384
5931
  this.fs.start(config.id, config.folder, []);
5385
5932
  this.notifyChanged();
5933
+ this.emitGame({ type: "session.create" });
5386
5934
  return this.toInfo(config);
5387
5935
  }
5388
5936
  close(id) {
@@ -5452,6 +6000,19 @@ class SessionManager {
5452
6000
  if (!config) return { branches: [], current: null, defaultBranch: null };
5453
6001
  return listBranches(config.folder);
5454
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
+ }
5455
6016
  /**
5456
6017
  * Initialize a git repository in a session's folder (so a non-repo session can
5457
6018
  * host parallel tasks). Returns the resulting git facts; throws git's message
@@ -5472,7 +6033,9 @@ class SessionManager {
5472
6033
  async createCheckpoint(sessionId, label) {
5473
6034
  const config = this.getConfig(sessionId);
5474
6035
  if (!config) throw new Error("Unknown session");
5475
- return createCheckpoint(config.folder, label);
6036
+ const checkpoint = await createCheckpoint(config.folder, label);
6037
+ this.emitGame({ type: "checkpoint.create" });
6038
+ return checkpoint;
5476
6039
  }
5477
6040
  /** Recent checkpoints for a session's repo, newest first. */
5478
6041
  async listCheckpoints(sessionId) {
@@ -5480,6 +6043,12 @@ class SessionManager {
5480
6043
  if (!config) return [];
5481
6044
  return listCheckpoints(config.folder);
5482
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
+ }
5483
6052
  /** Restore a session's working tree back to a checkpoint (guarded, reversible). */
5484
6053
  async restoreCheckpoint(sessionId, id) {
5485
6054
  const config = this.getConfig(sessionId);
@@ -5532,13 +6101,15 @@ class SessionManager {
5532
6101
  console.error("copyWorktreeIncludes failed", err);
5533
6102
  }
5534
6103
  }
6104
+ const claudeArgs = [];
6105
+ if (opts.plan) claudeArgs.push("--permission-mode", "plan");
6106
+ if (opts.model) claudeArgs.push("--model", opts.model);
5535
6107
  const claudeTerminal = {
5536
6108
  id: crypto.randomUUID(),
5537
6109
  kind: "claude",
5538
6110
  title: "claude",
5539
6111
  order: 0,
5540
- // A model picked for the task pins its claude via --model; absent = CLI default.
5541
- claudeArgs: opts.model ? ["--model", opts.model] : [],
6112
+ claudeArgs,
5542
6113
  startMode: "fresh"
5543
6114
  };
5544
6115
  const config = {
@@ -5559,7 +6130,9 @@ class SessionManager {
5559
6130
  // Default to direct merge; only persist a PR/auto choice when set, so
5560
6131
  // existing tasks and the common case stay exactly as before.
5561
6132
  ...opts.completion && opts.completion !== "merge" ? { completion: opts.completion } : {},
5562
- ...opts.autoComplete ? { autoComplete: true } : {}
6133
+ ...opts.autoComplete ? { autoComplete: true } : {},
6134
+ ...opts.plan ? { plan: true } : {},
6135
+ ...opts.plan && opts.autoAcceptPlan ? { autoAcceptPlan: true } : {}
5563
6136
  }
5564
6137
  };
5565
6138
  this.state.sessions.push(config);
@@ -5580,6 +6153,7 @@ class SessionManager {
5580
6153
  }, INITIAL_PROMPT_DELAY_MS);
5581
6154
  }
5582
6155
  this.notifyChanged();
6156
+ this.emitGame({ type: "worktree.create" });
5583
6157
  return this.toInfo(config);
5584
6158
  }
5585
6159
  /** Live git facts about a worktree task (uncommitted files, commits ahead). */
@@ -5661,7 +6235,12 @@ ${commit.output}` };
5661
6235
  }
5662
6236
  const result = await mergeBranch(baseFolder, branch, baseBranch);
5663
6237
  if (!result.ok) return { ...result, autoCommitted };
5664
- return { ...await this.pushAfterMerge(result, baseFolder, baseBranch), autoCommitted };
6238
+ const merged = { ...await this.pushAfterMerge(result, baseFolder, baseBranch), autoCommitted };
6239
+ this.emitGame({ type: "worktree.merge", meta: { commits: ahead ?? 0 } });
6240
+ if (this.state.features.some((f) => f.taskSessionId === sessionId)) {
6241
+ this.emitGame({ type: "feature.merge" });
6242
+ }
6243
+ return merged;
5665
6244
  }
5666
6245
  /** True if `branch` is the dedicated expansion branch of any auto-expand config. */
5667
6246
  isAutoExpandBranch(branch) {
@@ -5756,15 +6335,16 @@ ${commit.output}` };
5756
6335
  const title = prTitle(config.name, branch);
5757
6336
  const body = prBody(config.name, branch, baseBranch);
5758
6337
  const result = await createPullRequest(config.folder, branch, baseBranch, title, body);
6338
+ if (result.ok) this.emitGame({ type: "worktree.pr" });
5759
6339
  return { ...result, autoCommitted };
5760
6340
  }
5761
6341
  // ---------- auto-complete (auto-merge / auto-PR when claude finishes) --------
5762
6342
  /**
5763
6343
  * (Re)start the auto-complete idle countdown for a worktree task whose claude
5764
- * just went idle. Only arms once the task's claude has actually worked (so the
5765
- * boot-time idle never counts), and never while a prompt queue is still
5766
- * draining or an action is already in flight / done. The fire handler
5767
- * 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.
5768
6348
  */
5769
6349
  scheduleAutoComplete(sessionId) {
5770
6350
  const config = this.getConfig(sessionId);
@@ -5788,6 +6368,28 @@ ${commit.output}` };
5788
6368
  if (timer) clearTimeout(timer);
5789
6369
  this.autoCompleteTimers.delete(sessionId);
5790
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
+ }
5791
6393
  /**
5792
6394
  * Run a task's chosen completion automatically. Re-checks the gates that may
5793
6395
  * have changed during the countdown (still a worktree task, claude alive and
@@ -5803,7 +6405,8 @@ ${commit.output}` };
5803
6405
  if (config.promptQueue?.length) return;
5804
6406
  const terminal = this.claudeTargetTerminal(config);
5805
6407
  const pty2 = terminal ? this.ptys.get(terminal.id) : null;
5806
- 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;
5807
6410
  const dirty = await dirtyCount(config.folder) ?? 0;
5808
6411
  const ahead = await aheadCount(wt.baseFolder, wt.baseBranch, wt.branch) ?? 0;
5809
6412
  if (dirty === 0 && ahead === 0) return;
@@ -5921,6 +6524,7 @@ ${err.message}`
5921
6524
  this.tokenEff.clearApplied(sessionId);
5922
6525
  this.worktreeWorked.delete(sessionId);
5923
6526
  this.autoCompleteInFlight.delete(sessionId);
6527
+ this.planAccepted.delete(sessionId);
5924
6528
  this.state.sessions = this.state.sessions.filter((s) => s.id !== sessionId);
5925
6529
  if (this.state.activeSessionId === sessionId) this.state.activeSessionId = null;
5926
6530
  this.persistence.scheduleSave();
@@ -6040,6 +6644,7 @@ ${err.message}`
6040
6644
  const action = this.state.actions.find((a) => a.id === actionId);
6041
6645
  const command = action?.command.trim();
6042
6646
  if (!config || !action || !command) return null;
6647
+ this.emitGame({ type: "action.run" });
6043
6648
  if (action.shell === "claude") return this.runClaudeAction(config, command);
6044
6649
  let terminal = config.terminals.find((t) => t.actionId === action.id);
6045
6650
  let respawned = false;
@@ -6282,12 +6887,21 @@ ${err.message}`
6282
6887
  if (!config) return;
6283
6888
  const terminal = config.terminals.find((t) => t.id === terminalId);
6284
6889
  if (!terminal || terminal.kind !== "claude") return;
6890
+ const prevForAward = this.lastStatusForAward.get(terminalId);
6891
+ this.lastStatusForAward.set(terminalId, status);
6892
+ if (status === "done" && prevForAward === "working") {
6893
+ this.emitGame({ type: "session.turn" });
6894
+ }
6285
6895
  if (status === "done" || status === "idle") this.scheduleQueueDispatch(config.id);
6286
6896
  if (config.worktree?.autoComplete) {
6287
6897
  if (status === "working") this.worktreeWorked.add(config.id);
6288
- if (status === "idle") this.scheduleAutoComplete(config.id);
6898
+ if (status === "done" || status === "idle") this.scheduleAutoComplete(config.id);
6289
6899
  else this.clearAutoCompleteTimer(config.id);
6290
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
+ }
6291
6905
  if (status !== "needs-attention") return;
6292
6906
  const focused = win2?.isFocused() ?? false;
6293
6907
  const isActive = this.state.activeSessionId === config.id && config.activeTerminalId === terminalId;
@@ -7224,6 +7838,217 @@ class TokenEfficiencyService {
7224
7838
  }
7225
7839
  }
7226
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
+ }
7227
8052
  let win = null;
7228
8053
  const getWin = () => win;
7229
8054
  const persistence = new Persistence();
@@ -7286,18 +8111,31 @@ if (!gotLock) {
7286
8111
  electron.app.whenReady().then(() => {
7287
8112
  electron.app.setAppUserModelId("com.pedroferreira.maestro");
7288
8113
  persistence.load();
8114
+ const gameBus = new events.EventEmitter();
8115
+ const emitGame = (e) => {
8116
+ try {
8117
+ gameBus.emit("game", e);
8118
+ } catch {
8119
+ }
8120
+ };
7289
8121
  const fsService = new FsService(
7290
- (sessionId, events) => getWin()?.webContents.send("fs:events", sessionId, events),
8122
+ (sessionId, events2) => getWin()?.webContents.send("fs:events", sessionId, events2),
7291
8123
  () => persistence.state.settings.ignoreNames
7292
8124
  );
7293
8125
  const tokenEff = new TokenEfficiencyService(persistence);
7294
- const sessions = new SessionManager(persistence, fsService, tokenEff, getWin);
7295
- const sentinels = new SentinelService(persistence, getWin);
7296
- const features = new FeatureService(persistence, sessions);
7297
- const autoExpand = new AutoExpandService(persistence, features, getWin);
8126
+ const sessions = new SessionManager(persistence, fsService, tokenEff, getWin, emitGame);
8127
+ const sentinels = new SentinelService(persistence, getWin, emitGame);
8128
+ const features = new FeatureService(persistence, sessions, emitGame);
8129
+ const autoExpand = new AutoExpandService(persistence, features, getWin, emitGame);
7298
8130
  const conductor = new ConductorService(persistence, sessions, features, autoExpand, getWin);
7299
- const factory = new FactoryService(getWin);
8131
+ const factory = new FactoryService(getWin, emitGame);
7300
8132
  const agentRegistry = new AgentRegistryService(persistence, 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
+ });
8138
+ gameBus.on("game", (e) => gamification.award(e));
7301
8139
  registerIpc(
7302
8140
  sessions,
7303
8141
  fsService,
@@ -7309,6 +8147,8 @@ if (!gotLock) {
7309
8147
  factory,
7310
8148
  agentRegistry,
7311
8149
  tokenEff,
8150
+ gamification,
8151
+ usage,
7312
8152
  getWin
7313
8153
  );
7314
8154
  createWindow();
@@ -7318,13 +8158,18 @@ if (!gotLock) {
7318
8158
  autoExpand.start();
7319
8159
  tokenEff.start();
7320
8160
  factory.start();
7321
- conductor.onTurnComplete((messages) => factory.considerConversation(messages));
8161
+ gamification.start();
8162
+ conductor.onTurnComplete((messages) => {
8163
+ factory.considerConversation(messages);
8164
+ emitGame({ type: "conductor.turn" });
8165
+ });
7322
8166
  electron.app.on("activate", () => {
7323
8167
  if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
7324
8168
  });
7325
8169
  electron.app.on("before-quit", () => {
7326
8170
  tokenEff.dispose();
7327
8171
  agentRegistry.dispose();
8172
+ gamification.dispose();
7328
8173
  factory.dispose();
7329
8174
  conductor.dispose();
7330
8175
  autoExpand.dispose();