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