claude-maestro 0.1.16 → 0.1.18
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 +1248 -74
- package/out/preload/index.js +21 -2
- package/out/renderer/assets/{index-BYr39Do3.js → index-3GYmITDo.js} +2 -2
- package/out/renderer/assets/{index-t8kJBumF.js → index-BTPlkF9b.js} +2 -2
- package/out/renderer/assets/{index-BJ7HHtlW.css → index-BW8lIWcc.css} +4514 -3797
- package/out/renderer/assets/{index-QPm3UFYR.js → index-BuCQKc-Z.js} +2 -2
- package/out/renderer/assets/{index-CXmk7jHk.js → index-C27Lfjw4.js} +5 -5
- package/out/renderer/assets/{index-BLtm0KYA.js → index-C3tXFLVi.js} +2 -2
- package/out/renderer/assets/{index-RgZE5T_I.js → index-C9LaSnRB.js} +2 -2
- package/out/renderer/assets/{index-RGiHNShF.js → index-CA5CvYDB.js} +1 -1
- package/out/renderer/assets/{index-BQE9vCdv.js → index-CIscupyH.js} +5 -5
- package/out/renderer/assets/{index-BLk1t3tc.js → index-CYds92PN.js} +3 -3
- package/out/renderer/assets/{index-C7i4WyfA.js → index-CngVaxBn.js} +5 -5
- package/out/renderer/assets/{index-DkFO-IXC.js → index-CrDDqySQ.js} +5 -5
- package/out/renderer/assets/{index-Da2gJ_Wd.js → index-D14Kkf05.js} +2 -2
- package/out/renderer/assets/{index-2SQReYbL.js → index-D2J11DOI.js} +5 -5
- package/out/renderer/assets/{index-Btn996gP.js → index-D48Zxs0r.js} +2 -2
- package/out/renderer/assets/{index-BPgGZ4iY.js → index-DLIiZzc_.js} +2 -2
- package/out/renderer/assets/{index-B4G_DF7l.js → index-DSDf1IPU.js} +2 -2
- package/out/renderer/assets/{index-Ba0sUbhl.js → index-DTKHKVCm.js} +1420 -212
- package/out/renderer/assets/{index-BewSkGJT.js → index-DVz1TdHX.js} +4 -4
- package/out/renderer/assets/{index-BnId7J8H.js → index-DYixubwQ.js} +2 -2
- package/out/renderer/assets/{index-Dw7p19YD.js → index-DypnZCas.js} +3 -3
- package/out/renderer/assets/{index-DqrDpNqn.js → index-H1mXv84m.js} +2 -2
- package/out/renderer/assets/{index-CyQp9UDR.js → index-dBdOx5at.js} +5 -5
- package/out/renderer/assets/{index-CW9SAu99.js → index-tMx4AmRy.js} +2 -2
- package/out/renderer/index.html +2 -2
- package/package.json +2 -2
package/out/main/index.js
CHANGED
|
@@ -70,6 +70,29 @@ async function worktreeInfo(folder) {
|
|
|
70
70
|
return { isRepo: false, repoRoot: null, branch: null };
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
async function listBranches(folder) {
|
|
74
|
+
try {
|
|
75
|
+
const [refs, head] = await Promise.all([
|
|
76
|
+
git(folder, ["for-each-ref", "refs/heads", "--sort=refname", "--format=%(refname:short)"]),
|
|
77
|
+
git(folder, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
78
|
+
]);
|
|
79
|
+
if (refs.code !== 0) return { branches: [], current: null, defaultBranch: null };
|
|
80
|
+
const branches = refs.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
81
|
+
const current = head.code === 0 && head.stdout.trim() !== "HEAD" ? head.stdout.trim() : null;
|
|
82
|
+
let defaultBranch = null;
|
|
83
|
+
const originHead = await git(folder, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
84
|
+
if (originHead.code === 0) {
|
|
85
|
+
const name = originHead.stdout.trim().replace(/^origin\//, "");
|
|
86
|
+
if (branches.includes(name)) defaultBranch = name;
|
|
87
|
+
}
|
|
88
|
+
if (!defaultBranch) {
|
|
89
|
+
defaultBranch = ["main", "master"].find((b) => branches.includes(b)) ?? (current && branches.includes(current) ? current : branches[0] ?? null);
|
|
90
|
+
}
|
|
91
|
+
return { branches, current, defaultBranch };
|
|
92
|
+
} catch {
|
|
93
|
+
return { branches: [], current: null, defaultBranch: null };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
73
96
|
async function gitInit(folder) {
|
|
74
97
|
const init = await git(folder, ["init"]);
|
|
75
98
|
if (init.code !== 0) return init;
|
|
@@ -697,21 +720,21 @@ class StatusDetector {
|
|
|
697
720
|
}
|
|
698
721
|
}
|
|
699
722
|
const RING_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
700
|
-
const IS_WIN = process.platform === "win32";
|
|
701
|
-
function which(name) {
|
|
702
|
-
const out = IS_WIN ? child_process.spawnSync("where.exe", [name], { encoding: "utf8" }) : child_process.spawnSync("which", ["-a", name], { encoding: "utf8" });
|
|
723
|
+
const IS_WIN$1 = process.platform === "win32";
|
|
724
|
+
function which$1(name) {
|
|
725
|
+
const out = IS_WIN$1 ? child_process.spawnSync("where.exe", [name], { encoding: "utf8" }) : child_process.spawnSync("which", ["-a", name], { encoding: "utf8" });
|
|
703
726
|
if (out.status !== 0 || !out.stdout) return [];
|
|
704
727
|
return out.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
705
728
|
}
|
|
706
729
|
const cache = /* @__PURE__ */ new Map();
|
|
707
730
|
function resolveClaude() {
|
|
708
|
-
const candidates = which("claude");
|
|
709
|
-
const home = IS_WIN ? process.env.USERPROFILE : process.env.HOME;
|
|
731
|
+
const candidates = which$1("claude");
|
|
732
|
+
const home = IS_WIN$1 ? process.env.USERPROFILE : process.env.HOME;
|
|
710
733
|
if (home) {
|
|
711
|
-
const localBin = path.join(home, ".local", "bin", IS_WIN ? "claude.exe" : "claude");
|
|
734
|
+
const localBin = path.join(home, ".local", "bin", IS_WIN$1 ? "claude.exe" : "claude");
|
|
712
735
|
if (fs.existsSync(localBin)) candidates.push(localBin);
|
|
713
736
|
}
|
|
714
|
-
if (!IS_WIN) {
|
|
737
|
+
if (!IS_WIN$1) {
|
|
715
738
|
return candidates[0] ? { file: candidates[0], argsPrefix: [] } : null;
|
|
716
739
|
}
|
|
717
740
|
const exe = candidates.find((c) => c.toLowerCase().endsWith(".exe"));
|
|
@@ -721,19 +744,19 @@ function resolveClaude() {
|
|
|
721
744
|
return null;
|
|
722
745
|
}
|
|
723
746
|
function resolvePowershell() {
|
|
724
|
-
const pwsh = which(IS_WIN ? "pwsh.exe" : "pwsh")[0] ?? which("pwsh")[0];
|
|
747
|
+
const pwsh = which$1(IS_WIN$1 ? "pwsh.exe" : "pwsh")[0] ?? which$1("pwsh")[0];
|
|
725
748
|
if (pwsh) return { file: pwsh, argsPrefix: ["-NoLogo"] };
|
|
726
|
-
return IS_WIN ? { file: "powershell.exe", argsPrefix: ["-NoLogo"] } : null;
|
|
749
|
+
return IS_WIN$1 ? { file: "powershell.exe", argsPrefix: ["-NoLogo"] } : null;
|
|
727
750
|
}
|
|
728
751
|
function resolveCmd() {
|
|
729
|
-
return IS_WIN ? { file: process.env.ComSpec ?? "cmd.exe", argsPrefix: [] } : null;
|
|
752
|
+
return IS_WIN$1 ? { file: process.env.ComSpec ?? "cmd.exe", argsPrefix: [] } : null;
|
|
730
753
|
}
|
|
731
754
|
function resolveBash() {
|
|
732
|
-
if (!IS_WIN) {
|
|
733
|
-
const p = which("bash")[0] ?? (fs.existsSync("/bin/bash") ? "/bin/bash" : null);
|
|
755
|
+
if (!IS_WIN$1) {
|
|
756
|
+
const p = which$1("bash")[0] ?? (fs.existsSync("/bin/bash") ? "/bin/bash" : null);
|
|
734
757
|
return p ? { file: p, argsPrefix: ["-i", "-l"] } : null;
|
|
735
758
|
}
|
|
736
|
-
const onPath = which("bash.exe")[0] ?? which("bash")[0];
|
|
759
|
+
const onPath = which$1("bash.exe")[0] ?? which$1("bash")[0];
|
|
737
760
|
if (onPath) return { file: onPath, argsPrefix: ["-i", "-l"] };
|
|
738
761
|
const roots = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]].filter(Boolean);
|
|
739
762
|
for (const root2 of roots) {
|
|
@@ -745,8 +768,8 @@ function resolveBash() {
|
|
|
745
768
|
return null;
|
|
746
769
|
}
|
|
747
770
|
function resolveZsh() {
|
|
748
|
-
if (IS_WIN) return null;
|
|
749
|
-
const p = which("zsh")[0] ?? (fs.existsSync("/bin/zsh") ? "/bin/zsh" : null);
|
|
771
|
+
if (IS_WIN$1) return null;
|
|
772
|
+
const p = which$1("zsh")[0] ?? (fs.existsSync("/bin/zsh") ? "/bin/zsh" : null);
|
|
750
773
|
return p ? { file: p, argsPrefix: ["-i", "-l"] } : null;
|
|
751
774
|
}
|
|
752
775
|
function resolveKind(kind) {
|
|
@@ -774,17 +797,18 @@ function resolveKind(kind) {
|
|
|
774
797
|
}
|
|
775
798
|
const KIND_MISSING = {
|
|
776
799
|
claude: "claude CLI not found on PATH.\r\nInstall it with: npm install -g @anthropic-ai/claude-code",
|
|
777
|
-
bash: IS_WIN ? "bash not found.\r\nInstall Git for Windows to get Git Bash." : "bash not found on PATH.",
|
|
800
|
+
bash: IS_WIN$1 ? "bash not found.\r\nInstall Git for Windows to get Git Bash." : "bash not found on PATH.",
|
|
778
801
|
zsh: "zsh not found on PATH.",
|
|
779
802
|
powershell: "PowerShell (pwsh) not found on PATH.",
|
|
780
803
|
cmd: "cmd.exe is only available on Windows."
|
|
781
804
|
};
|
|
782
805
|
class PtySession {
|
|
783
|
-
constructor(config, folder, cb, sessionEnv = {}) {
|
|
806
|
+
constructor(config, folder, cb, sessionEnv = {}, dropEnv = []) {
|
|
784
807
|
this.config = config;
|
|
785
808
|
this.folder = folder;
|
|
786
809
|
this.cb = cb;
|
|
787
810
|
this.sessionEnv = sessionEnv;
|
|
811
|
+
this.dropEnv = dropEnv;
|
|
788
812
|
this.detector = new StatusDetector(
|
|
789
813
|
(s) => cb.onStatus(config.id, s),
|
|
790
814
|
config.kind !== "claude"
|
|
@@ -796,6 +820,8 @@ class PtySession {
|
|
|
796
820
|
attached = false;
|
|
797
821
|
detector;
|
|
798
822
|
exitCode = null;
|
|
823
|
+
/** Total chars of live process output this run (token-estimate feed). */
|
|
824
|
+
outputChars = 0;
|
|
799
825
|
get pid() {
|
|
800
826
|
return this.proc?.pid ?? null;
|
|
801
827
|
}
|
|
@@ -826,6 +852,9 @@ class PtySession {
|
|
|
826
852
|
for (const [k, v] of Object.entries(this.sessionEnv)) {
|
|
827
853
|
if (k.trim()) env[k] = v;
|
|
828
854
|
}
|
|
855
|
+
for (const k of this.dropEnv) {
|
|
856
|
+
if (!(k in this.sessionEnv)) delete env[k];
|
|
857
|
+
}
|
|
829
858
|
try {
|
|
830
859
|
this.proc = pty__namespace.spawn(cmd.file, args, {
|
|
831
860
|
cols: 120,
|
|
@@ -905,6 +934,7 @@ class PtySession {
|
|
|
905
934
|
handleData(data) {
|
|
906
935
|
this.chunks.push(data);
|
|
907
936
|
this.bufferedBytes += data.length;
|
|
937
|
+
this.outputChars += data.length;
|
|
908
938
|
while (this.bufferedBytes > RING_BUFFER_BYTES && this.chunks.length > 1) {
|
|
909
939
|
this.bufferedBytes -= this.chunks[0].length;
|
|
910
940
|
this.chunks.shift();
|
|
@@ -1426,17 +1456,25 @@ class ConductorService {
|
|
|
1426
1456
|
* `tagSessionId` focuses the turn on one session: the planner runs in that
|
|
1427
1457
|
* repo, sees only that session's state, and defaults its actions to it. Null
|
|
1428
1458
|
* (or an id that no longer exists) keeps the cross-repo conductor behaviour.
|
|
1459
|
+
*
|
|
1460
|
+
* `images` are files already saved by the conductor attach IPC; the planner
|
|
1461
|
+
* is told to Read each one (its Read tool renders images), so a screenshot
|
|
1462
|
+
* pasted into the chat is actually seen, not just mentioned.
|
|
1429
1463
|
*/
|
|
1430
|
-
async send(text, tagSessionId = null) {
|
|
1464
|
+
async send(text, tagSessionId = null, images = []) {
|
|
1431
1465
|
const trimmed = text.trim();
|
|
1432
|
-
|
|
1466
|
+
const attached = (images ?? []).filter(
|
|
1467
|
+
(i) => i && typeof i.path === "string" && fs.existsSync(i.path)
|
|
1468
|
+
);
|
|
1469
|
+
if (!trimmed && attached.length === 0 || this.busy) return;
|
|
1433
1470
|
this.busy = true;
|
|
1434
1471
|
const focusId = tagSessionId && this.sessions.getConfig(tagSessionId) ? tagSessionId : null;
|
|
1435
1472
|
const userMsg = {
|
|
1436
1473
|
id: crypto.randomUUID(),
|
|
1437
1474
|
role: "user",
|
|
1438
1475
|
text: trimmed,
|
|
1439
|
-
at: Date.now()
|
|
1476
|
+
at: Date.now(),
|
|
1477
|
+
...attached.length > 0 ? { images: attached } : {}
|
|
1440
1478
|
};
|
|
1441
1479
|
const assistantMsg = {
|
|
1442
1480
|
id: crypto.randomUUID(),
|
|
@@ -1449,7 +1487,7 @@ class ConductorService {
|
|
|
1449
1487
|
this.persistAndBroadcast();
|
|
1450
1488
|
try {
|
|
1451
1489
|
const snapshot = await this.buildSnapshot(focusId);
|
|
1452
|
-
const prompt = this.buildPrompt(snapshot, userMsg.text, focusId);
|
|
1490
|
+
const prompt = this.buildPrompt(snapshot, userMsg.text, focusId, attached);
|
|
1453
1491
|
const cwd = this.plannerCwd(focusId);
|
|
1454
1492
|
const out = await runHeadlessClaude({
|
|
1455
1493
|
cwd,
|
|
@@ -1472,11 +1510,21 @@ class ConductorService {
|
|
|
1472
1510
|
this.persistAndBroadcast();
|
|
1473
1511
|
}
|
|
1474
1512
|
}
|
|
1475
|
-
/**
|
|
1476
|
-
|
|
1513
|
+
/**
|
|
1514
|
+
* Approve and run one proposed action. For task-creating actions, `options`
|
|
1515
|
+
* carries the approval card's choices (base branch, model, PR/auto-merge);
|
|
1516
|
+
* they are applied to the created task and persisted as that repo's defaults
|
|
1517
|
+
* for the next proposal.
|
|
1518
|
+
*/
|
|
1519
|
+
async approve(messageId, actionId, options) {
|
|
1477
1520
|
const action = this.findAction(messageId, actionId);
|
|
1478
1521
|
if (!action || action.status !== "proposed") return;
|
|
1479
|
-
|
|
1522
|
+
if (options) this.saveTaskDefaults(action, options);
|
|
1523
|
+
await this.runAction(action, options);
|
|
1524
|
+
}
|
|
1525
|
+
/** Persisted per-repo task-card defaults for a session, or null when none yet. */
|
|
1526
|
+
getTaskDefaults(sessionId) {
|
|
1527
|
+
return this.persistence.state.taskOptionDefaults?.[sessionId] ?? null;
|
|
1480
1528
|
}
|
|
1481
1529
|
/** Approve every non-destructive proposed action on a turn, in order. */
|
|
1482
1530
|
async approveAll(messageId) {
|
|
@@ -1499,13 +1547,41 @@ class ConductorService {
|
|
|
1499
1547
|
findAction(messageId, actionId) {
|
|
1500
1548
|
return this.messages.find((m) => m.id === messageId)?.actions?.find((a) => a.id === actionId);
|
|
1501
1549
|
}
|
|
1550
|
+
/**
|
|
1551
|
+
* The session whose repo a task-creating action targets — where the card's
|
|
1552
|
+
* options apply and under which the per-repo defaults are stored.
|
|
1553
|
+
*/
|
|
1554
|
+
taskTargetSessionId(action) {
|
|
1555
|
+
const a = action.args;
|
|
1556
|
+
if (action.kind === "create_worktree_task") return String(a.parentSessionId ?? "") || null;
|
|
1557
|
+
if (action.kind === "author_feature") return String(a.sessionId ?? "") || null;
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1560
|
+
/** Remember the card's choices as the defaults for that repo's next proposal. */
|
|
1561
|
+
saveTaskDefaults(action, options) {
|
|
1562
|
+
const sessionId = this.taskTargetSessionId(action);
|
|
1563
|
+
if (!sessionId) return;
|
|
1564
|
+
const map = this.persistence.state.taskOptionDefaults ??= {};
|
|
1565
|
+
map[sessionId] = options;
|
|
1566
|
+
this.persistence.scheduleSave();
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* The effective task options for an action: the card's explicit choices when
|
|
1570
|
+
* given, else the repo's persisted defaults (so Approve-all and re-approvals
|
|
1571
|
+
* honour the last configuration), else none (the planner's args as-is).
|
|
1572
|
+
*/
|
|
1573
|
+
taskOptionsFor(action, explicit) {
|
|
1574
|
+
if (explicit) return explicit;
|
|
1575
|
+
const sessionId = this.taskTargetSessionId(action);
|
|
1576
|
+
return sessionId && this.persistence.state.taskOptionDefaults?.[sessionId] || void 0;
|
|
1577
|
+
}
|
|
1502
1578
|
/** Run one action by dispatching to the existing services; records the outcome. */
|
|
1503
|
-
async runAction(action) {
|
|
1579
|
+
async runAction(action, options) {
|
|
1504
1580
|
action.status = "running";
|
|
1505
1581
|
action.result = void 0;
|
|
1506
1582
|
this.persistAndBroadcast();
|
|
1507
1583
|
try {
|
|
1508
|
-
action.result = await this.dispatch(action);
|
|
1584
|
+
action.result = await this.dispatch(action, options);
|
|
1509
1585
|
action.status = "done";
|
|
1510
1586
|
} catch (err) {
|
|
1511
1587
|
action.status = "error";
|
|
@@ -1514,7 +1590,7 @@ class ConductorService {
|
|
|
1514
1590
|
this.persistAndBroadcast();
|
|
1515
1591
|
}
|
|
1516
1592
|
/** Map one approved action to a concrete service call; returns a result line. */
|
|
1517
|
-
async dispatch(action) {
|
|
1593
|
+
async dispatch(action, options) {
|
|
1518
1594
|
const a = action.args;
|
|
1519
1595
|
switch (action.kind) {
|
|
1520
1596
|
case "create_session": {
|
|
@@ -1530,9 +1606,19 @@ class ConductorService {
|
|
|
1530
1606
|
case "author_feature": {
|
|
1531
1607
|
const session = this.requireSession(String(a.sessionId ?? ""));
|
|
1532
1608
|
const feature = this.makeFeature(session.config.id, a);
|
|
1609
|
+
const opts = a.implement ? this.taskOptionsFor(action, options) : void 0;
|
|
1610
|
+
if (opts) {
|
|
1611
|
+
if (opts.createPr) feature.completion = "pr";
|
|
1612
|
+
if (opts.createPr || opts.autoMerge) feature.autoComplete = true;
|
|
1613
|
+
}
|
|
1533
1614
|
this.features.save(feature);
|
|
1534
1615
|
if (a.implement) {
|
|
1535
|
-
const
|
|
1616
|
+
const model = opts && opts.model !== "inherit" ? opts.model : void 0;
|
|
1617
|
+
const task = await this.features.implement(
|
|
1618
|
+
feature.id,
|
|
1619
|
+
opts?.baseBranch.trim() || void 0,
|
|
1620
|
+
model
|
|
1621
|
+
);
|
|
1536
1622
|
return `Drafted “${feature.title}” and spun a task to implement it (${task.config.name}).`;
|
|
1537
1623
|
}
|
|
1538
1624
|
return `Drafted feature “${feature.title}” with ${feature.specs.length} spec(s).`;
|
|
@@ -1547,13 +1633,24 @@ class ConductorService {
|
|
|
1547
1633
|
const parent = this.requireSession(String(a.parentSessionId ?? ""));
|
|
1548
1634
|
const branch = String(a.branch ?? "").trim();
|
|
1549
1635
|
if (!branch) throw new Error("A branch name is required.");
|
|
1636
|
+
const opts = this.taskOptionsFor(action, options);
|
|
1637
|
+
const baseBranch = (opts?.baseBranch.trim() || String(a.baseBranch ?? "")).trim();
|
|
1638
|
+
const model = opts && opts.model !== "inherit" ? opts.model : void 0;
|
|
1550
1639
|
const task = await this.sessions.createWorktreeSession(parent.config.id, {
|
|
1551
1640
|
name: a.name ? String(a.name) : branch,
|
|
1552
1641
|
branch,
|
|
1553
|
-
baseBranch
|
|
1554
|
-
initialPrompt: a.initialPrompt ? String(a.initialPrompt) : void 0
|
|
1642
|
+
baseBranch,
|
|
1643
|
+
initialPrompt: a.initialPrompt ? String(a.initialPrompt) : void 0,
|
|
1644
|
+
...opts?.createPr ? { completion: "pr" } : {},
|
|
1645
|
+
...opts && (opts.createPr || opts.autoMerge) ? { autoComplete: true } : {},
|
|
1646
|
+
...model ? { model } : {}
|
|
1555
1647
|
});
|
|
1556
|
-
|
|
1648
|
+
const extras = [
|
|
1649
|
+
model ? `model ${model}` : "",
|
|
1650
|
+
opts?.createPr ? "PR on completion" : "",
|
|
1651
|
+
opts?.autoMerge ? "auto-merge when done" : ""
|
|
1652
|
+
].filter(Boolean);
|
|
1653
|
+
return `Spun task “${task.config.name}” on branch ${branch}` + (extras.length ? ` (${extras.join(", ")})` : "") + ".";
|
|
1557
1654
|
}
|
|
1558
1655
|
case "queue_prompt": {
|
|
1559
1656
|
const session = this.requireSession(String(a.sessionId ?? ""));
|
|
@@ -1685,11 +1782,12 @@ class ConductorService {
|
|
|
1685
1782
|
return focusId ? { focusedSessionId: focusId, sessions } : { sessions };
|
|
1686
1783
|
}
|
|
1687
1784
|
/** Compose the full planner prompt: role, action catalog, snapshot, history, ask. */
|
|
1688
|
-
buildPrompt(snapshot, latest, focusId) {
|
|
1785
|
+
buildPrompt(snapshot, latest, focusId, images = []) {
|
|
1689
1786
|
const focusName = focusId ? this.sessions.getConfig(focusId)?.name : void 0;
|
|
1690
|
-
const history = this.messages.filter((m) => !m.pending && (m.text || m.actions && m.actions.length)).slice(-8 - 1, -1).map((m) => {
|
|
1787
|
+
const history = this.messages.filter((m) => !m.pending && (m.text || m.images?.length || m.actions && m.actions.length)).slice(-8 - 1, -1).map((m) => {
|
|
1691
1788
|
const acts = m.actions && m.actions.length ? ` [proposed: ${m.actions.map((a) => `${a.kind}(${a.status})`).join(", ")}]` : "";
|
|
1692
|
-
|
|
1789
|
+
const imgs = m.images?.length ? ` [attached: ${m.images.map((i) => i.path).join(", ")}]` : "";
|
|
1790
|
+
return `${m.role.toUpperCase()}: ${m.text}${imgs}${acts}`;
|
|
1693
1791
|
}).join("\n");
|
|
1694
1792
|
return [
|
|
1695
1793
|
"You are the Conductor for Maestro, a desktop app that runs many Claude Code CLI",
|
|
@@ -1712,7 +1810,8 @@ class ConductorService {
|
|
|
1712
1810
|
${history}
|
|
1713
1811
|
` : "",
|
|
1714
1812
|
`THE USER NOW SAYS:
|
|
1715
|
-
${latest}`,
|
|
1813
|
+
${latest || "(no text — see the attached images)"}`,
|
|
1814
|
+
images.length ? "\nTHE USER ATTACHED IMAGE FILE(S) — e.g. screenshots to analyze. View each one with the Read tool (it renders images) using these ABSOLUTE paths, BEFORE answering:\n" + images.map((i) => `- ${i.path}`).join("\n") : "",
|
|
1716
1815
|
"",
|
|
1717
1816
|
"Respond with EXACTLY ONE JSON object and nothing else — no markdown fences, no prose",
|
|
1718
1817
|
"around it — shaped like:",
|
|
@@ -1939,6 +2038,47 @@ function deleteArtifactFile(kind, name) {
|
|
|
1939
2038
|
} catch {
|
|
1940
2039
|
}
|
|
1941
2040
|
}
|
|
2041
|
+
function listInstalled() {
|
|
2042
|
+
const out = [];
|
|
2043
|
+
if (fs.existsSync(SKILLS_DIR)) {
|
|
2044
|
+
let dirs = [];
|
|
2045
|
+
try {
|
|
2046
|
+
dirs = fs.readdirSync(SKILLS_DIR);
|
|
2047
|
+
} catch {
|
|
2048
|
+
dirs = [];
|
|
2049
|
+
}
|
|
2050
|
+
for (const dir of dirs) {
|
|
2051
|
+
const file = path.join(SKILLS_DIR, dir, "SKILL.md");
|
|
2052
|
+
if (!fs.existsSync(file)) continue;
|
|
2053
|
+
try {
|
|
2054
|
+
const fm = parseFrontmatter(fs.readFileSync(file, "utf8"));
|
|
2055
|
+
out.push({ kind: "skill", name: fm.name?.trim() || dir, description: fm.description ?? "", filePath: file });
|
|
2056
|
+
} catch {
|
|
2057
|
+
out.push({ kind: "skill", name: dir, description: "", filePath: file });
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
if (fs.existsSync(AGENTS_DIR)) {
|
|
2062
|
+
let entries = [];
|
|
2063
|
+
try {
|
|
2064
|
+
entries = fs.readdirSync(AGENTS_DIR);
|
|
2065
|
+
} catch {
|
|
2066
|
+
entries = [];
|
|
2067
|
+
}
|
|
2068
|
+
for (const entry of entries) {
|
|
2069
|
+
if (!entry.endsWith(".md")) continue;
|
|
2070
|
+
const file = path.join(AGENTS_DIR, entry);
|
|
2071
|
+
const base = entry.replace(/\.md$/, "");
|
|
2072
|
+
try {
|
|
2073
|
+
const fm = parseFrontmatter(fs.readFileSync(file, "utf8"));
|
|
2074
|
+
out.push({ kind: "agent", name: fm.name?.trim() || base, description: fm.description ?? "", filePath: file });
|
|
2075
|
+
} catch {
|
|
2076
|
+
out.push({ kind: "agent", name: base, description: "", filePath: file });
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
2081
|
+
}
|
|
1942
2082
|
function scanAgents() {
|
|
1943
2083
|
if (!fs.existsSync(AGENTS_DIR)) return [];
|
|
1944
2084
|
let entries;
|
|
@@ -1966,6 +2106,7 @@ class FactoryStore {
|
|
|
1966
2106
|
file = path.join(electron.app.getPath("userData"), "factory.json");
|
|
1967
2107
|
timer = null;
|
|
1968
2108
|
state = { ...EMPTY };
|
|
2109
|
+
runs = [];
|
|
1969
2110
|
/** Load the saved registry (best-effort; an empty registry on any error). */
|
|
1970
2111
|
load() {
|
|
1971
2112
|
try {
|
|
@@ -1975,19 +2116,30 @@ class FactoryStore {
|
|
|
1975
2116
|
topics: Array.isArray(raw?.topics) ? raw.topics : [],
|
|
1976
2117
|
lessons: Array.isArray(raw?.lessons) ? raw.lessons : []
|
|
1977
2118
|
};
|
|
2119
|
+
this.runs = Array.isArray(raw?.runs) ? raw.runs : [];
|
|
1978
2120
|
} catch {
|
|
1979
2121
|
this.state = { ...EMPTY };
|
|
2122
|
+
this.runs = [];
|
|
1980
2123
|
}
|
|
1981
2124
|
return this.state;
|
|
1982
2125
|
}
|
|
1983
2126
|
get() {
|
|
1984
2127
|
return this.state;
|
|
1985
2128
|
}
|
|
2129
|
+
/** The persisted run audit trail (call after load()). */
|
|
2130
|
+
loadRuns() {
|
|
2131
|
+
return this.runs;
|
|
2132
|
+
}
|
|
1986
2133
|
/** Replace the registry and schedule a save. */
|
|
1987
2134
|
set(state) {
|
|
1988
2135
|
this.state = state;
|
|
1989
2136
|
this.scheduleSave();
|
|
1990
2137
|
}
|
|
2138
|
+
/** Replace the run audit trail and schedule a save. */
|
|
2139
|
+
setRuns(runs) {
|
|
2140
|
+
this.runs = runs;
|
|
2141
|
+
this.scheduleSave();
|
|
2142
|
+
}
|
|
1991
2143
|
scheduleSave() {
|
|
1992
2144
|
if (this.timer) clearTimeout(this.timer);
|
|
1993
2145
|
this.timer = setTimeout(() => this.saveNow(), 500);
|
|
@@ -2000,7 +2152,7 @@ class FactoryStore {
|
|
|
2000
2152
|
try {
|
|
2001
2153
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
2002
2154
|
const tmp = this.file + ".tmp";
|
|
2003
|
-
fs.writeFileSync(tmp, JSON.stringify(this.state, null, 2), "utf8");
|
|
2155
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...this.state, runs: this.runs }, null, 2), "utf8");
|
|
2004
2156
|
fs.renameSync(tmp, this.file);
|
|
2005
2157
|
} catch (err) {
|
|
2006
2158
|
console.error("Failed to persist factory registry:", err);
|
|
@@ -2024,14 +2176,17 @@ class FactoryService {
|
|
|
2024
2176
|
constructor(getWin2) {
|
|
2025
2177
|
this.getWin = getWin2;
|
|
2026
2178
|
this.state = this.store.load();
|
|
2179
|
+
this.runs = restoreRuns(this.store.loadRuns());
|
|
2027
2180
|
}
|
|
2028
2181
|
store = new FactoryStore();
|
|
2029
2182
|
state;
|
|
2030
2183
|
runs = [];
|
|
2031
2184
|
sources = null;
|
|
2032
|
-
/** The in-flight agent child, so dispose()/
|
|
2185
|
+
/** The in-flight agent child, so dispose()/cancel() can kill it. */
|
|
2033
2186
|
inFlight = null;
|
|
2034
2187
|
busy = false;
|
|
2188
|
+
/** Set by cancel(); the in-flight scan/author reports 'cancelled' instead of 'error'. */
|
|
2189
|
+
cancelRequested = false;
|
|
2035
2190
|
getState() {
|
|
2036
2191
|
return this.state;
|
|
2037
2192
|
}
|
|
@@ -2046,6 +2201,20 @@ class FactoryService {
|
|
|
2046
2201
|
this.inFlight = null;
|
|
2047
2202
|
this.store.saveNow();
|
|
2048
2203
|
}
|
|
2204
|
+
/** Cancel the in-flight scan/author agent, if any (the run reports 'cancelled'). */
|
|
2205
|
+
cancel() {
|
|
2206
|
+
if (!this.inFlight) return;
|
|
2207
|
+
this.cancelRequested = true;
|
|
2208
|
+
try {
|
|
2209
|
+
this.inFlight.kill();
|
|
2210
|
+
} catch {
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
/** Drop finished runs from the audit trail (a running one is kept). */
|
|
2214
|
+
clearRuns() {
|
|
2215
|
+
this.runs = this.runs.filter((r) => r.status === "running");
|
|
2216
|
+
this.broadcastRuns();
|
|
2217
|
+
}
|
|
2049
2218
|
// ---------- source discovery (phase 0) ----------
|
|
2050
2219
|
/**
|
|
2051
2220
|
* Enumerate the MCP contexts the factory can mine. The connected claude.ai
|
|
@@ -2124,6 +2293,7 @@ class FactoryService {
|
|
|
2124
2293
|
const source = sources.find((s) => s.server === serverKey);
|
|
2125
2294
|
if (!source) throw new Error(`Unknown source: ${serverKey}`);
|
|
2126
2295
|
this.busy = true;
|
|
2296
|
+
this.cancelRequested = false;
|
|
2127
2297
|
const run = {
|
|
2128
2298
|
id: crypto.randomUUID(),
|
|
2129
2299
|
source: source.server,
|
|
@@ -2155,9 +2325,9 @@ class FactoryService {
|
|
|
2155
2325
|
run.status = "done";
|
|
2156
2326
|
this.absorbTopics(parsed.newTopics, source.server);
|
|
2157
2327
|
} catch (err) {
|
|
2158
|
-
run.status = "error";
|
|
2328
|
+
run.status = this.cancelRequested ? "cancelled" : "error";
|
|
2159
2329
|
run.phase = "done";
|
|
2160
|
-
run.summary = err.message || String(err);
|
|
2330
|
+
run.summary = this.cancelRequested ? "Cancelled." : err.message || String(err);
|
|
2161
2331
|
} finally {
|
|
2162
2332
|
run.finishedAt = Date.now();
|
|
2163
2333
|
this.inFlight = null;
|
|
@@ -2256,13 +2426,14 @@ Lessons learned (respect these):
|
|
|
2256
2426
|
};
|
|
2257
2427
|
}
|
|
2258
2428
|
// ---------- author (phase 2) ----------
|
|
2259
|
-
/** Approve a candidate: author its file content and write it to ~/.claude. */
|
|
2429
|
+
/** Approve a candidate: author its file content and write it to ~/.claude. An errored candidate can be retried. */
|
|
2260
2430
|
async approve(runId, candidateId) {
|
|
2261
2431
|
const run = this.runs.find((r) => r.id === runId);
|
|
2262
2432
|
const candidate = run?.candidates.find((c) => c.id === candidateId);
|
|
2263
|
-
if (!run || !candidate || candidate.status !== "proposed") return;
|
|
2433
|
+
if (!run || !candidate || candidate.status !== "proposed" && candidate.status !== "error") return;
|
|
2264
2434
|
if (this.busy) return;
|
|
2265
2435
|
this.busy = true;
|
|
2436
|
+
this.cancelRequested = false;
|
|
2266
2437
|
candidate.status = "authoring";
|
|
2267
2438
|
candidate.result = void 0;
|
|
2268
2439
|
this.broadcastRuns();
|
|
@@ -2294,8 +2465,13 @@ Lessons learned (respect these):
|
|
|
2294
2465
|
candidate.filePath = filePath;
|
|
2295
2466
|
candidate.result = `Wrote ${candidate.kind} to ${filePath}`;
|
|
2296
2467
|
} catch (err) {
|
|
2297
|
-
|
|
2298
|
-
|
|
2468
|
+
if (this.cancelRequested) {
|
|
2469
|
+
candidate.status = "proposed";
|
|
2470
|
+
candidate.result = void 0;
|
|
2471
|
+
} else {
|
|
2472
|
+
candidate.status = "error";
|
|
2473
|
+
candidate.result = err.message || String(err);
|
|
2474
|
+
}
|
|
2299
2475
|
} finally {
|
|
2300
2476
|
this.inFlight = null;
|
|
2301
2477
|
this.busy = false;
|
|
@@ -2303,12 +2479,13 @@ Lessons learned (respect these):
|
|
|
2303
2479
|
this.broadcastState();
|
|
2304
2480
|
}
|
|
2305
2481
|
}
|
|
2306
|
-
/** Approve every still-proposed candidate on a run, in order. */
|
|
2482
|
+
/** Approve every still-proposed candidate on a run, in order (stops on cancel). */
|
|
2307
2483
|
async approveAll(runId) {
|
|
2308
2484
|
const run = this.runs.find((r) => r.id === runId);
|
|
2309
2485
|
if (!run) return;
|
|
2310
2486
|
for (const c of run.candidates) {
|
|
2311
2487
|
if (c.status === "proposed") await this.approve(runId, c.id);
|
|
2488
|
+
if (this.cancelRequested) break;
|
|
2312
2489
|
}
|
|
2313
2490
|
}
|
|
2314
2491
|
reject(runId, candidateId) {
|
|
@@ -2425,13 +2602,64 @@ Lessons learned (respect these):
|
|
|
2425
2602
|
deleteArtifact(id) {
|
|
2426
2603
|
const artifact = this.state.artifacts.find((a) => a.id === id);
|
|
2427
2604
|
if (!artifact) return;
|
|
2428
|
-
deleteArtifactFile(artifact.kind, artifact.name);
|
|
2605
|
+
if (!artifact.adopted) deleteArtifactFile(artifact.kind, artifact.name);
|
|
2606
|
+
this.unregister(id);
|
|
2607
|
+
}
|
|
2608
|
+
/** Remove an artifact from the registry WITHOUT touching its file. */
|
|
2609
|
+
unregister(id) {
|
|
2610
|
+
const artifact = this.state.artifacts.find((a) => a.id === id);
|
|
2611
|
+
if (!artifact) return;
|
|
2429
2612
|
this.state.artifacts = this.state.artifacts.filter((a) => a.id !== id);
|
|
2430
2613
|
for (const a of this.state.artifacts) {
|
|
2431
2614
|
a.relatedArtifacts = a.relatedArtifacts.filter((n) => n !== artifact.name);
|
|
2432
2615
|
}
|
|
2433
2616
|
this.persist();
|
|
2434
2617
|
}
|
|
2618
|
+
/** Read a registered artifact's file content (null when the file is missing). */
|
|
2619
|
+
readArtifact(id) {
|
|
2620
|
+
const artifact = this.state.artifacts.find((a) => a.id === id);
|
|
2621
|
+
if (!artifact) return null;
|
|
2622
|
+
try {
|
|
2623
|
+
return fs.readFileSync(artifact.filePath, "utf8");
|
|
2624
|
+
} catch {
|
|
2625
|
+
return null;
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
/** Reveal a registered artifact's file in the OS file manager. */
|
|
2629
|
+
revealArtifact(id) {
|
|
2630
|
+
const artifact = this.state.artifacts.find((a) => a.id === id);
|
|
2631
|
+
if (artifact && fs.existsSync(artifact.filePath)) electron.shell.showItemInFolder(artifact.filePath);
|
|
2632
|
+
}
|
|
2633
|
+
// ---------- registry↔disk audit (the lightweight validator) ----------
|
|
2634
|
+
/** Reconcile the registry against ~/.claude on disk. */
|
|
2635
|
+
audit() {
|
|
2636
|
+
const missingFileIds = this.state.artifacts.filter((a) => !fs.existsSync(a.filePath)).map((a) => a.id);
|
|
2637
|
+
const registered = new Set(this.state.artifacts.map((a) => `${a.kind}:${a.name}`));
|
|
2638
|
+
const unregistered = listInstalled().filter((i) => !registered.has(`${i.kind}:${i.name}`));
|
|
2639
|
+
return { missingFileIds, unregistered };
|
|
2640
|
+
}
|
|
2641
|
+
/** Adopt a pre-existing on-disk skill/agent into the registry (file is left as-is). */
|
|
2642
|
+
adopt(kind, name) {
|
|
2643
|
+
if (this.state.artifacts.some((a) => a.kind === kind && a.name === name)) return;
|
|
2644
|
+
const installed = listInstalled().find((i) => i.kind === kind && i.name === name);
|
|
2645
|
+
if (!installed) return;
|
|
2646
|
+
const now = Date.now();
|
|
2647
|
+
this.state.artifacts.push({
|
|
2648
|
+
id: crypto.randomUUID(),
|
|
2649
|
+
kind,
|
|
2650
|
+
name,
|
|
2651
|
+
filePath: installed.filePath,
|
|
2652
|
+
description: installed.description,
|
|
2653
|
+
topics: [],
|
|
2654
|
+
keywords: [],
|
|
2655
|
+
source: "adopted",
|
|
2656
|
+
relatedArtifacts: [],
|
|
2657
|
+
adopted: true,
|
|
2658
|
+
createdAt: now,
|
|
2659
|
+
updatedAt: now
|
|
2660
|
+
});
|
|
2661
|
+
this.persist();
|
|
2662
|
+
}
|
|
2435
2663
|
// ---------- backlog (topics-to-pursue) ----------
|
|
2436
2664
|
absorbTopics(topics, source) {
|
|
2437
2665
|
const have = new Set(this.state.topics.map((t) => t.title.toLowerCase()));
|
|
@@ -2495,9 +2723,27 @@ Lessons learned (respect these):
|
|
|
2495
2723
|
this.getWin()?.webContents.send("factory:changed", this.state);
|
|
2496
2724
|
}
|
|
2497
2725
|
broadcastRuns() {
|
|
2726
|
+
this.store.setRuns(this.runs);
|
|
2498
2727
|
this.getWin()?.webContents.send("factory:runs", this.runs);
|
|
2499
2728
|
}
|
|
2500
2729
|
}
|
|
2730
|
+
function restoreRuns(runs) {
|
|
2731
|
+
for (const run of runs) {
|
|
2732
|
+
if (run.status === "running") {
|
|
2733
|
+
run.status = "cancelled";
|
|
2734
|
+
run.phase = "done";
|
|
2735
|
+
run.finishedAt = run.finishedAt ?? Date.now();
|
|
2736
|
+
run.summary = run.summary || "Interrupted — the app was closed mid-scan.";
|
|
2737
|
+
}
|
|
2738
|
+
for (const c of run.candidates) {
|
|
2739
|
+
if (c.status === "authoring") {
|
|
2740
|
+
c.status = "proposed";
|
|
2741
|
+
c.result = void 0;
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
return runs;
|
|
2746
|
+
}
|
|
2501
2747
|
function toStringArray(v) {
|
|
2502
2748
|
if (!Array.isArray(v)) return [];
|
|
2503
2749
|
return [...new Set(v.map((x) => String(x).trim()).filter(Boolean))];
|
|
@@ -2563,9 +2809,10 @@ class FeatureService {
|
|
|
2563
2809
|
* Links the feature to the spawned session and flips it to 'implementing'.
|
|
2564
2810
|
* Throws (with git's message) if the parent isn't a repo or the worktree fails.
|
|
2565
2811
|
* `baseBranch` overrides which branch the task forks from and merges back
|
|
2566
|
-
* into (used by auto-expand to keep its growth on a dedicated branch)
|
|
2812
|
+
* into (used by auto-expand to keep its growth on a dedicated branch);
|
|
2813
|
+
* `model` pins the task claude's model (used by the Conductor's approval card).
|
|
2567
2814
|
*/
|
|
2568
|
-
async implement(featureId, baseBranch) {
|
|
2815
|
+
async implement(featureId, baseBranch, model) {
|
|
2569
2816
|
const feature = this.features.find((f) => f.id === featureId);
|
|
2570
2817
|
if (!feature) throw new Error("Unknown feature");
|
|
2571
2818
|
const parent = this.sessions.getConfig(feature.sessionId);
|
|
@@ -2580,7 +2827,8 @@ class FeatureService {
|
|
|
2580
2827
|
initialPrompt: implementPrompt(feature),
|
|
2581
2828
|
// Carry the feature's PR/merge preference onto the implementing task.
|
|
2582
2829
|
completion: feature.completion,
|
|
2583
|
-
autoComplete: feature.autoComplete
|
|
2830
|
+
autoComplete: feature.autoComplete,
|
|
2831
|
+
model
|
|
2584
2832
|
});
|
|
2585
2833
|
try {
|
|
2586
2834
|
const specAbs = path.join(session.config.folder, specRelPath(feature));
|
|
@@ -3145,6 +3393,7 @@ class UsageService {
|
|
|
3145
3393
|
return entries;
|
|
3146
3394
|
}
|
|
3147
3395
|
}
|
|
3396
|
+
const CONDUCTOR_ATTACH_SCOPE = "conductor";
|
|
3148
3397
|
function tokenize(template) {
|
|
3149
3398
|
const tokens = [];
|
|
3150
3399
|
const re = /"([^"]*)"|(\S+)/g;
|
|
@@ -3152,7 +3401,7 @@ function tokenize(template) {
|
|
|
3152
3401
|
while (m = re.exec(template)) tokens.push(m[1] ?? m[2]);
|
|
3153
3402
|
return tokens;
|
|
3154
3403
|
}
|
|
3155
|
-
function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, getWin2) {
|
|
3404
|
+
function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, tokenEff, getWin2) {
|
|
3156
3405
|
const rootOf = (id) => {
|
|
3157
3406
|
const config = sessions.getConfig(id);
|
|
3158
3407
|
if (!config) throw new Error(`Unknown session: ${id}`);
|
|
@@ -3209,6 +3458,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
|
|
|
3209
3458
|
"git:fileDiff",
|
|
3210
3459
|
(_e, sessionId, path2) => sessions.getGitFileDiff(sessionId, path2)
|
|
3211
3460
|
);
|
|
3461
|
+
electron.ipcMain.handle("git:branches", (_e, sessionId) => sessions.listBranches(sessionId));
|
|
3212
3462
|
electron.ipcMain.handle(
|
|
3213
3463
|
"checkpoint:create",
|
|
3214
3464
|
(_e, sessionId, label) => sessions.createCheckpoint(sessionId, label)
|
|
@@ -3295,11 +3545,11 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
|
|
|
3295
3545
|
electron.ipcMain.handle("conductor:list", () => conductor.list());
|
|
3296
3546
|
electron.ipcMain.handle(
|
|
3297
3547
|
"conductor:send",
|
|
3298
|
-
(_e, text, tagSessionId) => conductor.send(text, tagSessionId ?? null)
|
|
3548
|
+
(_e, text, tagSessionId, images) => conductor.send(text, tagSessionId ?? null, images ?? [])
|
|
3299
3549
|
);
|
|
3300
3550
|
electron.ipcMain.handle(
|
|
3301
3551
|
"conductor:approve",
|
|
3302
|
-
(_e, messageId, actionId) => conductor.approve(messageId, actionId)
|
|
3552
|
+
(_e, messageId, actionId, options) => conductor.approve(messageId, actionId, options)
|
|
3303
3553
|
);
|
|
3304
3554
|
electron.ipcMain.handle(
|
|
3305
3555
|
"conductor:approveAll",
|
|
@@ -3310,6 +3560,23 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
|
|
|
3310
3560
|
(_e, messageId, actionId) => conductor.reject(messageId, actionId)
|
|
3311
3561
|
);
|
|
3312
3562
|
electron.ipcMain.handle("conductor:clear", () => conductor.clear());
|
|
3563
|
+
electron.ipcMain.handle(
|
|
3564
|
+
"conductor:taskDefaults",
|
|
3565
|
+
(_e, sessionId) => conductor.getTaskDefaults(sessionId)
|
|
3566
|
+
);
|
|
3567
|
+
electron.ipcMain.handle("conductor:attachClipboard", () => attachClipboardImage(CONDUCTOR_ATTACH_SCOPE));
|
|
3568
|
+
electron.ipcMain.handle(
|
|
3569
|
+
"conductor:attachFile",
|
|
3570
|
+
(_e, srcPath) => attachImageFile(CONDUCTOR_ATTACH_SCOPE, srcPath)
|
|
3571
|
+
);
|
|
3572
|
+
electron.ipcMain.handle(
|
|
3573
|
+
"conductor:attachData",
|
|
3574
|
+
(_e, name, bytes) => attachImageData(CONDUCTOR_ATTACH_SCOPE, name, bytes)
|
|
3575
|
+
);
|
|
3576
|
+
electron.ipcMain.handle(
|
|
3577
|
+
"conductor:attachDelete",
|
|
3578
|
+
(_e, fileName) => deleteAttachment(CONDUCTOR_ATTACH_SCOPE, fileName)
|
|
3579
|
+
);
|
|
3313
3580
|
electron.ipcMain.handle("factory:listSources", (_e, refresh) => factory.listSources(refresh));
|
|
3314
3581
|
electron.ipcMain.handle("factory:state", () => factory.getState());
|
|
3315
3582
|
electron.ipcMain.handle("factory:runs", () => factory.listRuns());
|
|
@@ -3326,7 +3593,17 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
|
|
|
3326
3593
|
"factory:reject",
|
|
3327
3594
|
(_e, runId, candidateId) => factory.reject(runId, candidateId)
|
|
3328
3595
|
);
|
|
3596
|
+
electron.ipcMain.handle("factory:cancel", () => factory.cancel());
|
|
3597
|
+
electron.ipcMain.handle("factory:clearRuns", () => factory.clearRuns());
|
|
3329
3598
|
electron.ipcMain.handle("factory:deleteArtifact", (_e, id) => factory.deleteArtifact(id));
|
|
3599
|
+
electron.ipcMain.handle("factory:unregisterArtifact", (_e, id) => factory.unregister(id));
|
|
3600
|
+
electron.ipcMain.handle("factory:readArtifact", (_e, id) => factory.readArtifact(id));
|
|
3601
|
+
electron.ipcMain.handle("factory:revealArtifact", (_e, id) => factory.revealArtifact(id));
|
|
3602
|
+
electron.ipcMain.handle("factory:audit", () => factory.audit());
|
|
3603
|
+
electron.ipcMain.handle(
|
|
3604
|
+
"factory:adopt",
|
|
3605
|
+
(_e, kind, name) => factory.adopt(kind, name)
|
|
3606
|
+
);
|
|
3330
3607
|
electron.ipcMain.handle("factory:promoteTopic", (_e, id) => factory.promoteTopic(id));
|
|
3331
3608
|
electron.ipcMain.handle("factory:dismissTopic", (_e, id) => factory.dismissTopic(id));
|
|
3332
3609
|
electron.ipcMain.handle("factory:addLesson", (_e, text) => factory.addLesson(text));
|
|
@@ -3423,6 +3700,33 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
|
|
|
3423
3700
|
"attachments:delete",
|
|
3424
3701
|
(_e, id, fileName) => deleteAttachment(id, fileName)
|
|
3425
3702
|
);
|
|
3703
|
+
electron.ipcMain.handle("tokenEff:status", (_e, sessionId) => tokenEff.status(sessionId));
|
|
3704
|
+
electron.ipcMain.handle("tokenEff:saveGlobal", (_e, config) => {
|
|
3705
|
+
tokenEff.saveGlobal(config);
|
|
3706
|
+
getWin2()?.webContents.send("session:changed");
|
|
3707
|
+
});
|
|
3708
|
+
electron.ipcMain.handle(
|
|
3709
|
+
"tokenEff:setRepoOverride",
|
|
3710
|
+
(_e, sessionId, override) => {
|
|
3711
|
+
tokenEff.setRepoOverride(sessionId, override);
|
|
3712
|
+
getWin2()?.webContents.send("session:changed");
|
|
3713
|
+
}
|
|
3714
|
+
);
|
|
3715
|
+
electron.ipcMain.handle(
|
|
3716
|
+
"tokenEff:setSessionOverride",
|
|
3717
|
+
(_e, sessionId, override) => {
|
|
3718
|
+
tokenEff.setSessionOverride(sessionId, override);
|
|
3719
|
+
getWin2()?.webContents.send("session:changed");
|
|
3720
|
+
}
|
|
3721
|
+
);
|
|
3722
|
+
electron.ipcMain.handle(
|
|
3723
|
+
"tokenEff:refreshRepoMap",
|
|
3724
|
+
(_e, sessionId) => tokenEff.refreshRepoMap(sessionId)
|
|
3725
|
+
);
|
|
3726
|
+
electron.ipcMain.handle(
|
|
3727
|
+
"tokenEff:detectTools",
|
|
3728
|
+
(_e, refresh) => tokenEff.detectTools(refresh ?? false)
|
|
3729
|
+
);
|
|
3426
3730
|
const usage = new UsageService();
|
|
3427
3731
|
electron.ipcMain.handle("usage:get", () => usage.snapshot());
|
|
3428
3732
|
const usageLimits = new UsageLimitsService();
|
|
@@ -3461,6 +3765,17 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
|
|
|
3461
3765
|
getWin2()?.webContents.send("session:changed");
|
|
3462
3766
|
});
|
|
3463
3767
|
}
|
|
3768
|
+
const DEFAULT_TOKEN_EFFICIENCY = {
|
|
3769
|
+
enabled: false,
|
|
3770
|
+
outputCompression: true,
|
|
3771
|
+
codeGraph: true,
|
|
3772
|
+
truncationHooks: true,
|
|
3773
|
+
promptCachingHints: true,
|
|
3774
|
+
bashMaxOutputChars: 3e4,
|
|
3775
|
+
mcpMaxOutputTokens: 25e3,
|
|
3776
|
+
largeReadMaxKB: 256,
|
|
3777
|
+
repoMapMaxFiles: 400
|
|
3778
|
+
};
|
|
3464
3779
|
const DEFAULT_SETTINGS = {
|
|
3465
3780
|
editorCommand: 'code "${path}"',
|
|
3466
3781
|
scrollbackLines: 1e4,
|
|
@@ -3472,7 +3787,9 @@ const DEFAULT_SETTINGS = {
|
|
|
3472
3787
|
backgroundOpacity: 0.3,
|
|
3473
3788
|
watchdogEnabled: true,
|
|
3474
3789
|
watchdogStallMinutes: 10,
|
|
3475
|
-
watchdogUnansweredMinutes: 5
|
|
3790
|
+
watchdogUnansweredMinutes: 5,
|
|
3791
|
+
tokenEfficiency: DEFAULT_TOKEN_EFFICIENCY,
|
|
3792
|
+
tokenEfficiencyRepoOverrides: {}
|
|
3476
3793
|
};
|
|
3477
3794
|
const DEFAULT_CATEGORIES = [
|
|
3478
3795
|
{
|
|
@@ -3520,7 +3837,8 @@ const DEFAULT_STATE = {
|
|
|
3520
3837
|
settings: DEFAULT_SETTINGS,
|
|
3521
3838
|
categories: DEFAULT_CATEGORIES,
|
|
3522
3839
|
actions: [],
|
|
3523
|
-
features: []
|
|
3840
|
+
features: [],
|
|
3841
|
+
taskOptionDefaults: {}
|
|
3524
3842
|
};
|
|
3525
3843
|
function migrateSession(raw) {
|
|
3526
3844
|
if (Array.isArray(raw.terminals) && raw.terminals.length > 0) {
|
|
@@ -3570,10 +3888,20 @@ class Persistence {
|
|
|
3570
3888
|
...DEFAULT_STATE,
|
|
3571
3889
|
...raw,
|
|
3572
3890
|
window: { ...DEFAULT_STATE.window, ...raw.window ?? {} },
|
|
3573
|
-
settings: {
|
|
3891
|
+
settings: {
|
|
3892
|
+
...DEFAULT_SETTINGS,
|
|
3893
|
+
...raw.settings ?? {},
|
|
3894
|
+
// Nested object — merge so new fields gain their defaults on upgrade.
|
|
3895
|
+
tokenEfficiency: {
|
|
3896
|
+
...DEFAULT_TOKEN_EFFICIENCY,
|
|
3897
|
+
...raw.settings?.tokenEfficiency ?? {}
|
|
3898
|
+
},
|
|
3899
|
+
tokenEfficiencyRepoOverrides: raw.settings?.tokenEfficiencyRepoOverrides ?? {}
|
|
3900
|
+
},
|
|
3574
3901
|
categories: Array.isArray(raw.categories) ? raw.categories : DEFAULT_CATEGORIES,
|
|
3575
3902
|
actions: Array.isArray(raw.actions) ? raw.actions : [],
|
|
3576
3903
|
features: Array.isArray(raw.features) ? raw.features : [],
|
|
3904
|
+
taskOptionDefaults: raw.taskOptionDefaults && typeof raw.taskOptionDefaults === "object" ? raw.taskOptionDefaults : {},
|
|
3577
3905
|
sessions: Array.isArray(raw.sessions) ? raw.sessions.map(migrateSession) : []
|
|
3578
3906
|
};
|
|
3579
3907
|
} catch {
|
|
@@ -3620,7 +3948,7 @@ const ALLOWED_TOOLS = [
|
|
|
3620
3948
|
"Bash(gh pr diff:*)"
|
|
3621
3949
|
].join(",");
|
|
3622
3950
|
const SEVERITIES = ["info", "warning", "critical"];
|
|
3623
|
-
function gitHead(folder) {
|
|
3951
|
+
function gitHead$1(folder) {
|
|
3624
3952
|
return new Promise((resolve) => {
|
|
3625
3953
|
child_process.execFile(
|
|
3626
3954
|
"git",
|
|
@@ -3713,7 +4041,7 @@ class SentinelService {
|
|
|
3713
4041
|
if (this.polling.has(session.id) || !fs.existsSync(session.folder)) return;
|
|
3714
4042
|
this.polling.add(session.id);
|
|
3715
4043
|
try {
|
|
3716
|
-
const head = await gitHead(session.folder);
|
|
4044
|
+
const head = await gitHead$1(session.folder);
|
|
3717
4045
|
if (!head) return;
|
|
3718
4046
|
const previous = this.heads.get(session.id);
|
|
3719
4047
|
this.heads.set(session.id, head);
|
|
@@ -3876,14 +4204,14 @@ function parseAgentOutput(stdout) {
|
|
|
3876
4204
|
if (!text) return { summary: "", findings: [], error: "The agent produced no output." };
|
|
3877
4205
|
return { summary: text.slice(0, 500), findings: [] };
|
|
3878
4206
|
}
|
|
3879
|
-
const SETTINGS_REL = ".claude/settings.local.json";
|
|
4207
|
+
const SETTINGS_REL$1 = ".claude/settings.local.json";
|
|
3880
4208
|
const MCP_REL = ".mcp.json";
|
|
3881
|
-
function writeJsonAtomic(file, value) {
|
|
4209
|
+
function writeJsonAtomic$1(file, value) {
|
|
3882
4210
|
const tmp = file + ".tmp";
|
|
3883
4211
|
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
3884
4212
|
fs.renameSync(tmp, file);
|
|
3885
4213
|
}
|
|
3886
|
-
function readJson(file) {
|
|
4214
|
+
function readJson$1(file) {
|
|
3887
4215
|
if (!fs.existsSync(file)) return {};
|
|
3888
4216
|
try {
|
|
3889
4217
|
const v = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
@@ -3893,9 +4221,9 @@ function readJson(file) {
|
|
|
3893
4221
|
}
|
|
3894
4222
|
}
|
|
3895
4223
|
function applySettings(folder, category, allSkillNames, allManagedServerNames) {
|
|
3896
|
-
const file = path.join(folder, SETTINGS_REL);
|
|
4224
|
+
const file = path.join(folder, SETTINGS_REL$1);
|
|
3897
4225
|
const existed = fs.existsSync(file);
|
|
3898
|
-
const settings = readJson(file);
|
|
4226
|
+
const settings = readJson$1(file);
|
|
3899
4227
|
const overrides = settings.skillOverrides && typeof settings.skillOverrides === "object" ? { ...settings.skillOverrides } : {};
|
|
3900
4228
|
const enabled = new Set(category?.enabledSkills ?? []);
|
|
3901
4229
|
for (const name of allSkillNames) {
|
|
@@ -3918,19 +4246,19 @@ function applySettings(folder, category, allSkillNames, allManagedServerNames) {
|
|
|
3918
4246
|
else delete settings.enabledMcpjsonServers;
|
|
3919
4247
|
if (!existed && Object.keys(settings).length === 0) return;
|
|
3920
4248
|
fs.mkdirSync(path.join(folder, ".claude"), { recursive: true });
|
|
3921
|
-
writeJsonAtomic(file, settings);
|
|
4249
|
+
writeJsonAtomic$1(file, settings);
|
|
3922
4250
|
}
|
|
3923
4251
|
function applyMcp(folder, category, allManagedServerNames) {
|
|
3924
4252
|
const file = path.join(folder, MCP_REL);
|
|
3925
4253
|
const existed = fs.existsSync(file);
|
|
3926
|
-
const root2 = readJson(file);
|
|
4254
|
+
const root2 = readJson$1(file);
|
|
3927
4255
|
const servers = root2.mcpServers && typeof root2.mcpServers === "object" ? { ...root2.mcpServers } : {};
|
|
3928
4256
|
for (const name of allManagedServerNames) delete servers[name];
|
|
3929
4257
|
if (category) for (const s of category.mcpServers) servers[s.name] = s.config;
|
|
3930
4258
|
if (Object.keys(servers).length > 0) root2.mcpServers = servers;
|
|
3931
4259
|
else delete root2.mcpServers;
|
|
3932
4260
|
if (!existed && Object.keys(root2).length === 0) return;
|
|
3933
|
-
writeJsonAtomic(file, root2);
|
|
4261
|
+
writeJsonAtomic$1(file, root2);
|
|
3934
4262
|
}
|
|
3935
4263
|
function ensureGitExclude(folder) {
|
|
3936
4264
|
const file = excludeFilePathSync(folder);
|
|
@@ -3943,7 +4271,7 @@ function ensureGitExclude(folder) {
|
|
|
3943
4271
|
return;
|
|
3944
4272
|
}
|
|
3945
4273
|
const lines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
3946
|
-
const wanted = [SETTINGS_REL, MCP_REL].filter((p) => !lines.has(p));
|
|
4274
|
+
const wanted = [SETTINGS_REL$1, MCP_REL].filter((p) => !lines.has(p));
|
|
3947
4275
|
if (wanted.length === 0) return;
|
|
3948
4276
|
try {
|
|
3949
4277
|
fs.mkdirSync(infoDir, { recursive: true });
|
|
@@ -4092,9 +4420,10 @@ const STATUS_PRIORITY = [
|
|
|
4092
4420
|
"exited"
|
|
4093
4421
|
];
|
|
4094
4422
|
class SessionManager {
|
|
4095
|
-
constructor(persistence2, fs2, getWin2) {
|
|
4423
|
+
constructor(persistence2, fs2, tokenEff, getWin2) {
|
|
4096
4424
|
this.persistence = persistence2;
|
|
4097
4425
|
this.fs = fs2;
|
|
4426
|
+
this.tokenEff = tokenEff;
|
|
4098
4427
|
this.getWin = getWin2;
|
|
4099
4428
|
}
|
|
4100
4429
|
/** Keyed by terminal id, across all sessions. */
|
|
@@ -4153,6 +4482,7 @@ class SessionManager {
|
|
|
4153
4482
|
skillNames,
|
|
4154
4483
|
this.managedServerNames()
|
|
4155
4484
|
);
|
|
4485
|
+
this.tokenEff.apply(config);
|
|
4156
4486
|
}
|
|
4157
4487
|
sessionOfTerminal(terminalId) {
|
|
4158
4488
|
return this.state.sessions.find((s) => s.terminals.some((t) => t.id === terminalId));
|
|
@@ -4199,6 +4529,7 @@ class SessionManager {
|
|
|
4199
4529
|
}
|
|
4200
4530
|
this.fs.stop(id);
|
|
4201
4531
|
this.clearQueueTimer(id);
|
|
4532
|
+
this.tokenEff.clearApplied(id);
|
|
4202
4533
|
void deleteAllAttachments(id).catch(() => {
|
|
4203
4534
|
});
|
|
4204
4535
|
this.state.sessions = this.state.sessions.filter((s) => s.id !== id);
|
|
@@ -4248,6 +4579,12 @@ class SessionManager {
|
|
|
4248
4579
|
if (!config) return { diff: "", binary: false, truncated: false };
|
|
4249
4580
|
return gitFileDiff(config.folder, path2);
|
|
4250
4581
|
}
|
|
4582
|
+
/** Local branches + default branch of a session's repo (base-branch picker). */
|
|
4583
|
+
async listBranches(sessionId) {
|
|
4584
|
+
const config = this.getConfig(sessionId);
|
|
4585
|
+
if (!config) return { branches: [], current: null, defaultBranch: null };
|
|
4586
|
+
return listBranches(config.folder);
|
|
4587
|
+
}
|
|
4251
4588
|
/**
|
|
4252
4589
|
* Initialize a git repository in a session's folder (so a non-repo session can
|
|
4253
4590
|
* host parallel tasks). Returns the resulting git facts; throws git's message
|
|
@@ -4333,7 +4670,8 @@ class SessionManager {
|
|
|
4333
4670
|
kind: "claude",
|
|
4334
4671
|
title: "claude",
|
|
4335
4672
|
order: 0,
|
|
4336
|
-
|
|
4673
|
+
// A model picked for the task pins its claude via --model; absent = CLI default.
|
|
4674
|
+
claudeArgs: opts.model ? ["--model", opts.model] : [],
|
|
4337
4675
|
startMode: "fresh"
|
|
4338
4676
|
};
|
|
4339
4677
|
const config = {
|
|
@@ -4619,10 +4957,26 @@ ${commit.output}` };
|
|
|
4619
4957
|
event.url = pr.url;
|
|
4620
4958
|
event.output = pr.output;
|
|
4621
4959
|
} else {
|
|
4622
|
-
const
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4960
|
+
const baseDirty = await dirtyCount(wt.baseFolder) ?? 0;
|
|
4961
|
+
const conflicts = baseDirty > 0 ? null : await mergeConflictFiles(wt.baseFolder, wt.branch, wt.baseBranch).catch(
|
|
4962
|
+
() => null
|
|
4963
|
+
);
|
|
4964
|
+
if (baseDirty > 0) {
|
|
4965
|
+
event.ok = false;
|
|
4966
|
+
event.output = `Auto-merge skipped: the base working tree (${wt.baseFolder}) has ${baseDirty} uncommitted file(s). Commit or stash them, then merge the task from the sidebar.`;
|
|
4967
|
+
} else if (conflicts && conflicts.length > 0) {
|
|
4968
|
+
event.ok = false;
|
|
4969
|
+
event.conflict = true;
|
|
4970
|
+
event.output = `Auto-merge skipped: merging "${wt.branch}" into "${wt.baseBranch}" would conflict in:
|
|
4971
|
+
` + conflicts.map((f) => ` • ${f}`).join("\n") + `
|
|
4972
|
+
|
|
4973
|
+
The base repo was left untouched — merge from the sidebar to resolve.`;
|
|
4974
|
+
} else {
|
|
4975
|
+
const merge = await this.mergeWorktree(sessionId, true);
|
|
4976
|
+
event.ok = merge.ok;
|
|
4977
|
+
event.conflict = merge.conflict;
|
|
4978
|
+
event.output = merge.output;
|
|
4979
|
+
}
|
|
4626
4980
|
}
|
|
4627
4981
|
const fresh = this.getConfig(sessionId);
|
|
4628
4982
|
if (fresh?.worktree) {
|
|
@@ -4697,6 +5051,7 @@ ${err.message}`
|
|
|
4697
5051
|
}
|
|
4698
5052
|
this.clearQueueTimer(sessionId);
|
|
4699
5053
|
this.clearAutoCompleteTimer(sessionId);
|
|
5054
|
+
this.tokenEff.clearApplied(sessionId);
|
|
4700
5055
|
this.worktreeWorked.delete(sessionId);
|
|
4701
5056
|
this.autoCompleteInFlight.delete(sessionId);
|
|
4702
5057
|
this.state.sessions = this.state.sessions.filter((s) => s.id !== sessionId);
|
|
@@ -5030,6 +5385,7 @@ ${err.message}`
|
|
|
5030
5385
|
this.fs.stopAll();
|
|
5031
5386
|
}
|
|
5032
5387
|
spawnTerminal(config, terminal, mode, history) {
|
|
5388
|
+
const te = terminal.kind === "claude" ? this.tokenEff.envFor(config) : { set: {}, drop: [] };
|
|
5033
5389
|
const session = new PtySession(
|
|
5034
5390
|
terminal,
|
|
5035
5391
|
config.folder,
|
|
@@ -5044,11 +5400,13 @@ ${err.message}`
|
|
|
5044
5400
|
// Snapshot lazily at write time — the store throttles to ~1 write/s.
|
|
5045
5401
|
onOutput: (id) => this.scrollback.markDirty(id, () => session.tail(SCROLLBACK_MAX_BYTES))
|
|
5046
5402
|
},
|
|
5047
|
-
config.env ?? {}
|
|
5403
|
+
{ ...te.set, ...config.env ?? {} },
|
|
5404
|
+
te.drop
|
|
5048
5405
|
);
|
|
5049
5406
|
if (history) session.seedHistory(history + SCROLLBACK_DIVIDER);
|
|
5050
5407
|
this.ptys.set(terminal.id, session);
|
|
5051
5408
|
session.spawn(mode);
|
|
5409
|
+
if (terminal.kind === "claude") this.tokenEff.markApplied(config);
|
|
5052
5410
|
}
|
|
5053
5411
|
handleStatus(terminalId, status) {
|
|
5054
5412
|
const win2 = this.getWin();
|
|
@@ -5147,7 +5505,8 @@ ${err.message}`
|
|
|
5147
5505
|
lastOutputAt: pty2?.detector.lastOutput ?? 0,
|
|
5148
5506
|
exitCode: pty2?.exitCode ?? null,
|
|
5149
5507
|
statusSince,
|
|
5150
|
-
watchdog: pty2?.alive ? this.watchdogAlert(terminal, status, statusSince) : null
|
|
5508
|
+
watchdog: pty2?.alive ? this.watchdogAlert(terminal, status, statusSince) : null,
|
|
5509
|
+
outputChars: pty2?.outputChars ?? 0
|
|
5151
5510
|
};
|
|
5152
5511
|
}
|
|
5153
5512
|
/**
|
|
@@ -5187,6 +5546,817 @@ ${err.message}`
|
|
|
5187
5546
|
this.getWin()?.webContents.send("session:changed");
|
|
5188
5547
|
}
|
|
5189
5548
|
}
|
|
5549
|
+
const OUTPUT_FILTER = String.raw`#!/usr/bin/env node
|
|
5550
|
+
// Maestro Token Efficiency — built-in output filter (auto-generated, do not edit).
|
|
5551
|
+
import { appendFileSync } from 'node:fs'
|
|
5552
|
+
|
|
5553
|
+
function arg(name) {
|
|
5554
|
+
const i = process.argv.indexOf(name)
|
|
5555
|
+
return i >= 0 ? process.argv[i + 1] : null
|
|
5556
|
+
}
|
|
5557
|
+
const statsFile = arg('--stats')
|
|
5558
|
+
|
|
5559
|
+
const HEAD_LINES = 120
|
|
5560
|
+
const TAIL_LINES = 80
|
|
5561
|
+
const KEEP_MAX = 60
|
|
5562
|
+
const KEEP_ERR = /\b(error|fail|failed|failure|exception|fatal|panic|traceback)\b/i
|
|
5563
|
+
const KEEP_WARN = /\b(warn|warning)\b/i
|
|
5564
|
+
|
|
5565
|
+
function compress(raw) {
|
|
5566
|
+
// Strip ANSI CSI/OSC sequences; keep only the final state of \r-redrawn lines.
|
|
5567
|
+
const text = raw
|
|
5568
|
+
.replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, '')
|
|
5569
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
5570
|
+
const lines = []
|
|
5571
|
+
for (const rawLine of text.split('\n')) {
|
|
5572
|
+
const line = rawLine.includes('\r') ? rawLine.slice(rawLine.lastIndexOf('\r') + 1) : rawLine
|
|
5573
|
+
lines.push(line.replace(/\s+$/, ''))
|
|
5574
|
+
}
|
|
5575
|
+
// Collapse runs of identical lines and squeeze blank-line runs.
|
|
5576
|
+
const collapsed = []
|
|
5577
|
+
let i = 0
|
|
5578
|
+
while (i < lines.length) {
|
|
5579
|
+
let j = i + 1
|
|
5580
|
+
while (j < lines.length && lines[j] === lines[i]) j++
|
|
5581
|
+
const n = j - i
|
|
5582
|
+
if (lines[i].trim() === '') {
|
|
5583
|
+
if (collapsed.length > 0 && collapsed[collapsed.length - 1] !== '') collapsed.push('')
|
|
5584
|
+
} else if (n > 2) {
|
|
5585
|
+
collapsed.push(lines[i] + ' [repeated ' + n + 'x]')
|
|
5586
|
+
} else {
|
|
5587
|
+
for (let k = 0; k < n; k++) collapsed.push(lines[i])
|
|
5588
|
+
}
|
|
5589
|
+
i = j
|
|
5590
|
+
}
|
|
5591
|
+
if (collapsed.length <= HEAD_LINES + TAIL_LINES + 20) return collapsed.join('\n')
|
|
5592
|
+
const head = collapsed.slice(0, HEAD_LINES)
|
|
5593
|
+
const tail = collapsed.slice(collapsed.length - TAIL_LINES)
|
|
5594
|
+
const middle = collapsed.slice(HEAD_LINES, collapsed.length - TAIL_LINES)
|
|
5595
|
+
// Errors outrank warnings for the keep budget — a wall of deprecation
|
|
5596
|
+
// warnings must never crowd out the one failure line.
|
|
5597
|
+
const errs = []
|
|
5598
|
+
const warns = []
|
|
5599
|
+
for (const line of middle) {
|
|
5600
|
+
if (KEEP_ERR.test(line)) {
|
|
5601
|
+
if (errs.length < KEEP_MAX) errs.push(line)
|
|
5602
|
+
} else if (KEEP_WARN.test(line)) {
|
|
5603
|
+
if (warns.length < KEEP_MAX) warns.push(line)
|
|
5604
|
+
}
|
|
5605
|
+
}
|
|
5606
|
+
const kept = errs.concat(warns.slice(0, Math.max(0, KEEP_MAX - errs.length)))
|
|
5607
|
+
const omitted = middle.length - kept.length
|
|
5608
|
+
return head
|
|
5609
|
+
.concat(
|
|
5610
|
+
'',
|
|
5611
|
+
'... ' + omitted + ' lines omitted by Maestro token filter' +
|
|
5612
|
+
(kept.length ? ' (errors/warnings kept below)' : '') + ' ...',
|
|
5613
|
+
''
|
|
5614
|
+
)
|
|
5615
|
+
.concat(kept.length ? kept.concat('') : [])
|
|
5616
|
+
.concat(tail)
|
|
5617
|
+
.join('\n')
|
|
5618
|
+
}
|
|
5619
|
+
|
|
5620
|
+
const chunks = []
|
|
5621
|
+
process.stdin.on('data', (c) => chunks.push(c))
|
|
5622
|
+
process.stdin.on('end', () => {
|
|
5623
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
5624
|
+
let out
|
|
5625
|
+
try {
|
|
5626
|
+
out = compress(raw)
|
|
5627
|
+
} catch {
|
|
5628
|
+
out = raw // never lose output to a filter bug
|
|
5629
|
+
}
|
|
5630
|
+
process.stdout.write(out)
|
|
5631
|
+
if (statsFile) {
|
|
5632
|
+
try {
|
|
5633
|
+
appendFileSync(
|
|
5634
|
+
statsFile,
|
|
5635
|
+
JSON.stringify({ at: Date.now(), cwd: process.cwd(), kind: 'filter', orig: raw.length, out: out.length }) + '\n'
|
|
5636
|
+
)
|
|
5637
|
+
} catch {}
|
|
5638
|
+
}
|
|
5639
|
+
})
|
|
5640
|
+
`;
|
|
5641
|
+
const BASH_COMPRESS = String.raw`#!/usr/bin/env node
|
|
5642
|
+
// Maestro Token Efficiency — Bash command compression hook (auto-generated, do not edit).
|
|
5643
|
+
import { appendFileSync, readFileSync } from 'node:fs'
|
|
5644
|
+
|
|
5645
|
+
function arg(name) {
|
|
5646
|
+
const i = process.argv.indexOf(name)
|
|
5647
|
+
return i >= 0 ? process.argv[i + 1] : null
|
|
5648
|
+
}
|
|
5649
|
+
const statsFile = arg('--stats')
|
|
5650
|
+
const filter = arg('--filter')
|
|
5651
|
+
const rtk = arg('--rtk') === '1'
|
|
5652
|
+
|
|
5653
|
+
let input
|
|
5654
|
+
try {
|
|
5655
|
+
input = JSON.parse(readFileSync(0, 'utf8'))
|
|
5656
|
+
} catch {
|
|
5657
|
+
process.exit(0)
|
|
5658
|
+
}
|
|
5659
|
+
if (!input || input.tool_name !== 'Bash' || !input.tool_input) process.exit(0)
|
|
5660
|
+
const command = String(input.tool_input.command || '').trim()
|
|
5661
|
+
if (!command) process.exit(0)
|
|
5662
|
+
|
|
5663
|
+
// Already shaped/structured output — don't double-process.
|
|
5664
|
+
if (/[|<>]/.test(command)) process.exit(0)
|
|
5665
|
+
if (/\brtk\b/.test(command) || command.includes('output-filter.mjs')) process.exit(0)
|
|
5666
|
+
|
|
5667
|
+
const NOISY = [
|
|
5668
|
+
/^git (status|log|diff|show|fetch|pull|blame)\b/,
|
|
5669
|
+
/^(npm|pnpm|yarn|bun) (install|ci|i)\b/,
|
|
5670
|
+
/^(npm|pnpm|yarn|bun) (run )?(build|test|lint|typecheck|tsc)\b/,
|
|
5671
|
+
/^npx (tsc|jest|vitest|eslint|playwright|mocha)\b/,
|
|
5672
|
+
/^(cargo|go) (build|test|check|vet)\b/,
|
|
5673
|
+
/^(mvn|gradle|gradlew|\.\/gradlew)\b/,
|
|
5674
|
+
/^(pytest|tox|tsc|make)\b/,
|
|
5675
|
+
/^dotnet (build|test|restore)\b/,
|
|
5676
|
+
/^pip3? install\b/
|
|
5677
|
+
]
|
|
5678
|
+
if (!NOISY.some((re) => re.test(command))) process.exit(0)
|
|
5679
|
+
|
|
5680
|
+
let updated = null
|
|
5681
|
+
let kind = null
|
|
5682
|
+
if (rtk && /^git /.test(command)) {
|
|
5683
|
+
updated = 'rtk ' + command
|
|
5684
|
+
kind = 'rtk'
|
|
5685
|
+
} else if (filter) {
|
|
5686
|
+
updated =
|
|
5687
|
+
'set -o pipefail; { ' + command + '; } 2>&1 | node "' + filter + '"' +
|
|
5688
|
+
(statsFile ? ' --stats "' + statsFile + '"' : '')
|
|
5689
|
+
kind = 'wrap'
|
|
5690
|
+
}
|
|
5691
|
+
if (!updated) process.exit(0)
|
|
5692
|
+
|
|
5693
|
+
if (statsFile && kind === 'rtk') {
|
|
5694
|
+
try {
|
|
5695
|
+
appendFileSync(
|
|
5696
|
+
statsFile,
|
|
5697
|
+
JSON.stringify({ at: Date.now(), cwd: process.cwd(), kind: 'rtk' }) + '\n'
|
|
5698
|
+
)
|
|
5699
|
+
} catch {}
|
|
5700
|
+
}
|
|
5701
|
+
process.stdout.write(
|
|
5702
|
+
JSON.stringify({
|
|
5703
|
+
hookSpecificOutput: {
|
|
5704
|
+
hookEventName: 'PreToolUse',
|
|
5705
|
+
permissionDecision: 'allow',
|
|
5706
|
+
permissionDecisionReason: 'Maestro token efficiency: noisy command output is compressed (' + kind + ')',
|
|
5707
|
+
updatedInput: Object.assign({}, input.tool_input, { command: updated })
|
|
5708
|
+
}
|
|
5709
|
+
})
|
|
5710
|
+
)
|
|
5711
|
+
`;
|
|
5712
|
+
const READ_GUARD = String.raw`#!/usr/bin/env node
|
|
5713
|
+
// Maestro Token Efficiency — large-read guard hook (auto-generated, do not edit).
|
|
5714
|
+
import { appendFileSync, readFileSync, statSync } from 'node:fs'
|
|
5715
|
+
|
|
5716
|
+
function arg(name) {
|
|
5717
|
+
const i = process.argv.indexOf(name)
|
|
5718
|
+
return i >= 0 ? process.argv[i + 1] : null
|
|
5719
|
+
}
|
|
5720
|
+
const statsFile = arg('--stats')
|
|
5721
|
+
const maxKB = Math.max(8, parseInt(arg('--max-kb') || '256', 10) || 256)
|
|
5722
|
+
|
|
5723
|
+
let input
|
|
5724
|
+
try {
|
|
5725
|
+
input = JSON.parse(readFileSync(0, 'utf8'))
|
|
5726
|
+
} catch {
|
|
5727
|
+
process.exit(0)
|
|
5728
|
+
}
|
|
5729
|
+
if (!input || input.tool_name !== 'Read' || !input.tool_input) process.exit(0)
|
|
5730
|
+
const file = String(input.tool_input.file_path || '')
|
|
5731
|
+
if (!file) process.exit(0)
|
|
5732
|
+
// A targeted slice is deliberate — allow it.
|
|
5733
|
+
if (input.tool_input.offset || input.tool_input.limit) process.exit(0)
|
|
5734
|
+
|
|
5735
|
+
const base = (file.split(/[\\/]/).pop() || '').toLowerCase()
|
|
5736
|
+
const norm = file.replace(/\\/g, '/').toLowerCase()
|
|
5737
|
+
const LOCKFILES = [
|
|
5738
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'bun.lock',
|
|
5739
|
+
'cargo.lock', 'poetry.lock', 'pipfile.lock', 'uv.lock', 'composer.lock', 'gemfile.lock',
|
|
5740
|
+
'go.sum', 'flake.lock'
|
|
5741
|
+
]
|
|
5742
|
+
const sink =
|
|
5743
|
+
LOCKFILES.indexOf(base) >= 0 ||
|
|
5744
|
+
/\/(node_modules|dist|build|out|target|\.venv|__pycache__|coverage)\//.test(norm) ||
|
|
5745
|
+
/\.(log|jsonl|map)$/.test(base) ||
|
|
5746
|
+
base.includes('.min.')
|
|
5747
|
+
if (!sink) process.exit(0)
|
|
5748
|
+
|
|
5749
|
+
let size = 0
|
|
5750
|
+
try {
|
|
5751
|
+
size = statSync(file).size
|
|
5752
|
+
} catch {
|
|
5753
|
+
process.exit(0)
|
|
5754
|
+
}
|
|
5755
|
+
if (size <= maxKB * 1024) process.exit(0)
|
|
5756
|
+
|
|
5757
|
+
if (statsFile) {
|
|
5758
|
+
try {
|
|
5759
|
+
appendFileSync(
|
|
5760
|
+
statsFile,
|
|
5761
|
+
JSON.stringify({ at: Date.now(), cwd: process.cwd(), kind: 'blocked-read', bytes: size }) + '\n'
|
|
5762
|
+
)
|
|
5763
|
+
} catch {}
|
|
5764
|
+
}
|
|
5765
|
+
const sizeKB = Math.round(size / 1024)
|
|
5766
|
+
process.stdout.write(
|
|
5767
|
+
JSON.stringify({
|
|
5768
|
+
hookSpecificOutput: {
|
|
5769
|
+
hookEventName: 'PreToolUse',
|
|
5770
|
+
permissionDecision: 'deny',
|
|
5771
|
+
permissionDecisionReason:
|
|
5772
|
+
'Maestro token guard: "' + file + '" is ' + sizeKB + ' KB of low-signal content ' +
|
|
5773
|
+
'(lockfile/log/build artifact). Reading it whole would waste a large amount of context. ' +
|
|
5774
|
+
'Use Grep to find the specific entry you need, or Read with offset/limit for a targeted slice.'
|
|
5775
|
+
}
|
|
5776
|
+
})
|
|
5777
|
+
)
|
|
5778
|
+
`;
|
|
5779
|
+
const SESSION_CONTEXT = String.raw`#!/usr/bin/env node
|
|
5780
|
+
// Maestro Token Efficiency — repo-map session context hook (auto-generated, do not edit).
|
|
5781
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5782
|
+
import { join } from 'node:path'
|
|
5783
|
+
|
|
5784
|
+
try {
|
|
5785
|
+
const map = join(process.cwd(), '.claude', 'maestro-repo-map.md')
|
|
5786
|
+
if (existsSync(map)) {
|
|
5787
|
+
const text = readFileSync(map, 'utf8').trim()
|
|
5788
|
+
if (text) {
|
|
5789
|
+
process.stdout.write(
|
|
5790
|
+
'Repo symbol map (generated by Maestro). Use it to jump straight to the right file/symbol ' +
|
|
5791
|
+
'with Grep or a targeted Read instead of reading whole files:\n\n' + text + '\n'
|
|
5792
|
+
)
|
|
5793
|
+
}
|
|
5794
|
+
}
|
|
5795
|
+
} catch {}
|
|
5796
|
+
`;
|
|
5797
|
+
const SCRIPT_FILES = {
|
|
5798
|
+
outputFilter: "output-filter.mjs",
|
|
5799
|
+
bashCompress: "bash-compress.mjs",
|
|
5800
|
+
readGuard: "read-guard.mjs",
|
|
5801
|
+
sessionContext: "session-context.mjs"
|
|
5802
|
+
};
|
|
5803
|
+
function ensureScripts(scriptsDir) {
|
|
5804
|
+
fs.mkdirSync(scriptsDir, { recursive: true });
|
|
5805
|
+
const sources = {
|
|
5806
|
+
outputFilter: OUTPUT_FILTER,
|
|
5807
|
+
bashCompress: BASH_COMPRESS,
|
|
5808
|
+
readGuard: READ_GUARD,
|
|
5809
|
+
sessionContext: SESSION_CONTEXT
|
|
5810
|
+
};
|
|
5811
|
+
const out = {};
|
|
5812
|
+
for (const key of Object.keys(SCRIPT_FILES)) {
|
|
5813
|
+
const path$1 = path.join(scriptsDir, SCRIPT_FILES[key]);
|
|
5814
|
+
fs.writeFileSync(path$1, sources[key], "utf8");
|
|
5815
|
+
out[key] = path$1;
|
|
5816
|
+
}
|
|
5817
|
+
return out;
|
|
5818
|
+
}
|
|
5819
|
+
const IS_WIN = process.platform === "win32";
|
|
5820
|
+
const REPO_MAP_REL = ".claude/maestro-repo-map.md";
|
|
5821
|
+
const SETTINGS_REL = ".claude/settings.local.json";
|
|
5822
|
+
const HEAD_POLL_MS = 6e4;
|
|
5823
|
+
const REPO_MAP_MAX_BYTES = 24 * 1024;
|
|
5824
|
+
const MAX_SYMBOLS_PER_FILE = 15;
|
|
5825
|
+
const MAP_FILE_MAX_BYTES = 512 * 1024;
|
|
5826
|
+
const STATS_ROTATE_BYTES = 2 * 1024 * 1024;
|
|
5827
|
+
const BLOCKED_READ_MAX_TOKENS = 5e4;
|
|
5828
|
+
const EXTRACTORS = [
|
|
5829
|
+
{
|
|
5830
|
+
exts: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
|
5831
|
+
patterns: [
|
|
5832
|
+
/^export\s+(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum|const|let)\s+([A-Za-z_$][\w$]*)/gm,
|
|
5833
|
+
/^(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/gm,
|
|
5834
|
+
/^(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/gm
|
|
5835
|
+
]
|
|
5836
|
+
},
|
|
5837
|
+
{
|
|
5838
|
+
exts: [".py"],
|
|
5839
|
+
patterns: [/^(?:async\s+)?def\s+(\w+)/gm, /^class\s+(\w+)/gm]
|
|
5840
|
+
},
|
|
5841
|
+
{
|
|
5842
|
+
exts: [".java", ".cs", ".kt"],
|
|
5843
|
+
patterns: [
|
|
5844
|
+
/^\s*(?:public|protected|internal)?\s*(?:static\s+|final\s+|sealed\s+|abstract\s+|data\s+)*(?:class|interface|enum|record)\s+(\w+)/gm,
|
|
5845
|
+
/^\s*(?:public|protected)\s+(?:static\s+)?[\w<>[\],.\s?]+?\s+(\w+)\s*\(/gm
|
|
5846
|
+
]
|
|
5847
|
+
},
|
|
5848
|
+
{
|
|
5849
|
+
exts: [".go"],
|
|
5850
|
+
patterns: [/^func\s+(?:\([^)]*\)\s+)?(\w+)/gm, /^type\s+(\w+)/gm]
|
|
5851
|
+
},
|
|
5852
|
+
{
|
|
5853
|
+
exts: [".rs"],
|
|
5854
|
+
patterns: [
|
|
5855
|
+
/^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)/gm,
|
|
5856
|
+
/^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait)\s+(\w+)/gm
|
|
5857
|
+
]
|
|
5858
|
+
},
|
|
5859
|
+
{
|
|
5860
|
+
exts: [".rb"],
|
|
5861
|
+
patterns: [/^\s*(?:def|class|module)\s+([\w.?!]+)/gm]
|
|
5862
|
+
},
|
|
5863
|
+
{
|
|
5864
|
+
exts: [".php"],
|
|
5865
|
+
patterns: [/function\s+(\w+)/gm, /^\s*(?:abstract\s+|final\s+)?class\s+(\w+)/gm]
|
|
5866
|
+
}
|
|
5867
|
+
];
|
|
5868
|
+
const EXT_TO_PATTERNS = /* @__PURE__ */ new Map();
|
|
5869
|
+
for (const group of EXTRACTORS) for (const ext of group.exts) EXT_TO_PATTERNS.set(ext, group.patterns);
|
|
5870
|
+
function which(name) {
|
|
5871
|
+
const out = IS_WIN ? child_process.spawnSync("where.exe", [name], { encoding: "utf8" }) : child_process.spawnSync("which", [name], { encoding: "utf8" });
|
|
5872
|
+
if (out.status !== 0 || !out.stdout) return null;
|
|
5873
|
+
return out.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0] ?? null;
|
|
5874
|
+
}
|
|
5875
|
+
function readJson(file) {
|
|
5876
|
+
if (!fs.existsSync(file)) return {};
|
|
5877
|
+
try {
|
|
5878
|
+
const v = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
5879
|
+
return v && typeof v === "object" ? v : {};
|
|
5880
|
+
} catch {
|
|
5881
|
+
return {};
|
|
5882
|
+
}
|
|
5883
|
+
}
|
|
5884
|
+
function writeJsonAtomic(file, value) {
|
|
5885
|
+
const tmp = file + ".tmp";
|
|
5886
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
5887
|
+
fs.renameSync(tmp, file);
|
|
5888
|
+
}
|
|
5889
|
+
function defined(o) {
|
|
5890
|
+
const out = {};
|
|
5891
|
+
if (!o) return out;
|
|
5892
|
+
for (const [k, v] of Object.entries(o)) {
|
|
5893
|
+
if (v !== void 0) out[k] = v;
|
|
5894
|
+
}
|
|
5895
|
+
return out;
|
|
5896
|
+
}
|
|
5897
|
+
function underFolder(path2, folder) {
|
|
5898
|
+
const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
5899
|
+
const a = norm(path2);
|
|
5900
|
+
const b = norm(folder);
|
|
5901
|
+
return a === b || a.startsWith(b + "/");
|
|
5902
|
+
}
|
|
5903
|
+
function gitHead(folder) {
|
|
5904
|
+
return new Promise((resolve) => {
|
|
5905
|
+
child_process.execFile(
|
|
5906
|
+
"git",
|
|
5907
|
+
["rev-parse", "HEAD"],
|
|
5908
|
+
{ cwd: folder, windowsHide: true },
|
|
5909
|
+
(err, stdout) => resolve(err ? null : stdout.trim() || null)
|
|
5910
|
+
);
|
|
5911
|
+
});
|
|
5912
|
+
}
|
|
5913
|
+
function ensureMapExcluded(folder) {
|
|
5914
|
+
const file = excludeFilePathSync(folder);
|
|
5915
|
+
if (!file) return;
|
|
5916
|
+
try {
|
|
5917
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
5918
|
+
const lines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
5919
|
+
if (lines.has(REPO_MAP_REL)) return;
|
|
5920
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
5921
|
+
const suffix = current.length && !current.endsWith("\n") ? "\n" : "";
|
|
5922
|
+
fs.writeFileSync(file, current + suffix + REPO_MAP_REL + "\n", "utf8");
|
|
5923
|
+
} catch {
|
|
5924
|
+
}
|
|
5925
|
+
}
|
|
5926
|
+
class TokenEfficiencyService {
|
|
5927
|
+
constructor(persistence2) {
|
|
5928
|
+
this.persistence = persistence2;
|
|
5929
|
+
try {
|
|
5930
|
+
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
5931
|
+
this.scripts = ensureScripts(path.join(this.baseDir, "scripts"));
|
|
5932
|
+
} catch (err) {
|
|
5933
|
+
console.error("Token efficiency: failed to write hook scripts", err);
|
|
5934
|
+
this.scripts = null;
|
|
5935
|
+
}
|
|
5936
|
+
}
|
|
5937
|
+
baseDir = path.join(electron.app.getPath("userData"), "token-efficiency");
|
|
5938
|
+
statsFile = path.join(this.baseDir, "stats.jsonl");
|
|
5939
|
+
scripts = null;
|
|
5940
|
+
rtkPath;
|
|
5941
|
+
// undefined = not probed yet
|
|
5942
|
+
nodePath;
|
|
5943
|
+
/** Effective config each session's claude was last spawned with. */
|
|
5944
|
+
applied = /* @__PURE__ */ new Map();
|
|
5945
|
+
/** Repo-map facts per repo folder (the folder the map was generated in). */
|
|
5946
|
+
repoMaps = /* @__PURE__ */ new Map();
|
|
5947
|
+
/** Last seen HEAD per session folder, for change-driven map refresh. */
|
|
5948
|
+
lastHead = /* @__PURE__ */ new Map();
|
|
5949
|
+
/** Folders with a map generation in flight (dedupe). */
|
|
5950
|
+
generating = /* @__PURE__ */ new Set();
|
|
5951
|
+
statsCache = null;
|
|
5952
|
+
pollTimer = null;
|
|
5953
|
+
/** Begin the git-change poll that keeps repo maps fresh. Idempotent. */
|
|
5954
|
+
start() {
|
|
5955
|
+
if (this.pollTimer) return;
|
|
5956
|
+
this.pollTimer = setInterval(() => void this.pollGitChanges(), HEAD_POLL_MS);
|
|
5957
|
+
}
|
|
5958
|
+
dispose() {
|
|
5959
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
5960
|
+
this.pollTimer = null;
|
|
5961
|
+
}
|
|
5962
|
+
// ---------- config resolution ----------
|
|
5963
|
+
/** The override key a session's repo resolves to (worktree tasks → base repo). */
|
|
5964
|
+
repoKeyOf(config) {
|
|
5965
|
+
return config.worktree?.baseFolder ?? config.folder;
|
|
5966
|
+
}
|
|
5967
|
+
/** Global ⊕ repo override ⊕ session override. */
|
|
5968
|
+
resolveEffective(config) {
|
|
5969
|
+
const settings = this.persistence.state.settings;
|
|
5970
|
+
const repoOv = settings.tokenEfficiencyRepoOverrides[this.repoKeyOf(config)];
|
|
5971
|
+
return {
|
|
5972
|
+
...settings.tokenEfficiency,
|
|
5973
|
+
...defined(repoOv),
|
|
5974
|
+
...defined(config.tokenEfficiency)
|
|
5975
|
+
};
|
|
5976
|
+
}
|
|
5977
|
+
/** Probe for external tools (cached; `refresh` re-probes). */
|
|
5978
|
+
detectTools(refresh = false) {
|
|
5979
|
+
if (refresh || this.rtkPath === void 0) this.rtkPath = which("rtk");
|
|
5980
|
+
if (refresh || this.nodePath === void 0) this.nodePath = which("node");
|
|
5981
|
+
return {
|
|
5982
|
+
rtk: { found: this.rtkPath !== null, path: this.rtkPath },
|
|
5983
|
+
nodeFound: this.nodePath !== null
|
|
5984
|
+
};
|
|
5985
|
+
}
|
|
5986
|
+
// ---------- materialization (runs before claude spawns) ----------
|
|
5987
|
+
/**
|
|
5988
|
+
* Materialize the session's effective config into its repo: write/remove our
|
|
5989
|
+
* managed hook entries and kick a repo-map (re)generation. Idempotent and
|
|
5990
|
+
* reversible — with the toolkit off every trace is removed again. Never
|
|
5991
|
+
* throws (a failure must not block a session launch).
|
|
5992
|
+
*/
|
|
5993
|
+
apply(config) {
|
|
5994
|
+
if (!fs.existsSync(config.folder)) return;
|
|
5995
|
+
const effective = this.resolveEffective(config);
|
|
5996
|
+
try {
|
|
5997
|
+
this.applyHooks(config.folder, effective);
|
|
5998
|
+
} catch (err) {
|
|
5999
|
+
console.error("Token efficiency: applying hooks failed for", config.folder, err);
|
|
6000
|
+
}
|
|
6001
|
+
if (effective.enabled && effective.codeGraph) {
|
|
6002
|
+
void this.ensureRepoMap(config.folder, effective, false);
|
|
6003
|
+
} else {
|
|
6004
|
+
this.removeRepoMap(config.folder);
|
|
6005
|
+
}
|
|
6006
|
+
}
|
|
6007
|
+
/** Env overlay for a claude spawn: variables to set and to drop. */
|
|
6008
|
+
envFor(config) {
|
|
6009
|
+
const effective = this.resolveEffective(config);
|
|
6010
|
+
const set = {};
|
|
6011
|
+
const drop = [];
|
|
6012
|
+
if (!effective.enabled) return { set, drop };
|
|
6013
|
+
if (effective.truncationHooks) {
|
|
6014
|
+
set.BASH_MAX_OUTPUT_LENGTH = String(effective.bashMaxOutputChars);
|
|
6015
|
+
set.MAX_MCP_OUTPUT_TOKENS = String(effective.mcpMaxOutputTokens);
|
|
6016
|
+
}
|
|
6017
|
+
if (effective.promptCachingHints) drop.push("DISABLE_PROMPT_CACHING");
|
|
6018
|
+
return { set, drop };
|
|
6019
|
+
}
|
|
6020
|
+
/** Record what a session's claude was actually spawned with (for drift). */
|
|
6021
|
+
markApplied(config) {
|
|
6022
|
+
this.applied.set(config.id, this.resolveEffective(config));
|
|
6023
|
+
}
|
|
6024
|
+
clearApplied(sessionId) {
|
|
6025
|
+
this.applied.delete(sessionId);
|
|
6026
|
+
}
|
|
6027
|
+
/**
|
|
6028
|
+
* Rewrite our managed hook entries in the repo's settings.local.json. Ours
|
|
6029
|
+
* are recognized by the scripts-dir path inside the command string; foreign
|
|
6030
|
+
* hooks are preserved untouched (same managed-namespace contract as
|
|
6031
|
+
* ContextProfile).
|
|
6032
|
+
*/
|
|
6033
|
+
applyHooks(folder, effective) {
|
|
6034
|
+
const file = path.join(folder, SETTINGS_REL);
|
|
6035
|
+
const existed = fs.existsSync(file);
|
|
6036
|
+
const settings = readJson(file);
|
|
6037
|
+
const marker = this.scripts ? path.dirname(this.scripts.outputFilter) : null;
|
|
6038
|
+
const hooks = settings.hooks && typeof settings.hooks === "object" ? { ...settings.hooks } : {};
|
|
6039
|
+
const isOurs = (entry) => {
|
|
6040
|
+
const hookList = entry?.hooks;
|
|
6041
|
+
if (!Array.isArray(hookList) || !marker) return false;
|
|
6042
|
+
return hookList.some((h) => {
|
|
6043
|
+
const cmd = h?.command;
|
|
6044
|
+
return typeof cmd === "string" && cmd.includes(marker);
|
|
6045
|
+
});
|
|
6046
|
+
};
|
|
6047
|
+
for (const event of ["PreToolUse", "SessionStart"]) {
|
|
6048
|
+
const entries = Array.isArray(hooks[event]) ? hooks[event].filter((e) => !isOurs(e)) : [];
|
|
6049
|
+
if (entries.length > 0) hooks[event] = entries;
|
|
6050
|
+
else delete hooks[event];
|
|
6051
|
+
}
|
|
6052
|
+
const active = effective.enabled && this.scripts && this.detectTools().nodeFound;
|
|
6053
|
+
if (active && this.scripts) {
|
|
6054
|
+
const node = "node";
|
|
6055
|
+
const q = (p) => '"' + p + '"';
|
|
6056
|
+
const pre = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
6057
|
+
if (effective.outputCompression) {
|
|
6058
|
+
const rtkFlag = this.detectTools().rtk.found ? "1" : "0";
|
|
6059
|
+
pre.push({
|
|
6060
|
+
matcher: "Bash",
|
|
6061
|
+
hooks: [
|
|
6062
|
+
{
|
|
6063
|
+
type: "command",
|
|
6064
|
+
command: node + " " + q(this.scripts.bashCompress) + " --stats " + q(this.statsFile) + " --filter " + q(this.scripts.outputFilter) + " --rtk " + rtkFlag,
|
|
6065
|
+
timeout: 10
|
|
6066
|
+
}
|
|
6067
|
+
]
|
|
6068
|
+
});
|
|
6069
|
+
}
|
|
6070
|
+
if (effective.truncationHooks) {
|
|
6071
|
+
pre.push({
|
|
6072
|
+
matcher: "Read",
|
|
6073
|
+
hooks: [
|
|
6074
|
+
{
|
|
6075
|
+
type: "command",
|
|
6076
|
+
command: node + " " + q(this.scripts.readGuard) + " --stats " + q(this.statsFile) + " --max-kb " + effective.largeReadMaxKB,
|
|
6077
|
+
timeout: 10
|
|
6078
|
+
}
|
|
6079
|
+
]
|
|
6080
|
+
});
|
|
6081
|
+
}
|
|
6082
|
+
if (pre.length > 0) hooks.PreToolUse = pre;
|
|
6083
|
+
if (effective.codeGraph) {
|
|
6084
|
+
const start = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
|
|
6085
|
+
start.push({
|
|
6086
|
+
hooks: [
|
|
6087
|
+
{ type: "command", command: node + " " + q(this.scripts.sessionContext), timeout: 10 }
|
|
6088
|
+
]
|
|
6089
|
+
});
|
|
6090
|
+
hooks.SessionStart = start;
|
|
6091
|
+
}
|
|
6092
|
+
}
|
|
6093
|
+
if (Object.keys(hooks).length > 0) settings.hooks = hooks;
|
|
6094
|
+
else delete settings.hooks;
|
|
6095
|
+
if (!existed && Object.keys(settings).length === 0) return;
|
|
6096
|
+
fs.mkdirSync(path.join(folder, ".claude"), { recursive: true });
|
|
6097
|
+
writeJsonAtomic(file, settings);
|
|
6098
|
+
}
|
|
6099
|
+
// ---------- repo map (code graph) ----------
|
|
6100
|
+
/**
|
|
6101
|
+
* (Re)generate a session folder's repo map when missing or its HEAD moved
|
|
6102
|
+
* (`force` regenerates regardless). The map is written into the repo for the
|
|
6103
|
+
* SessionStart hook to pick up, and kept out of git via info/exclude.
|
|
6104
|
+
*/
|
|
6105
|
+
async ensureRepoMap(folder, effective, force) {
|
|
6106
|
+
if (this.generating.has(folder)) return this.repoMaps.get(folder) ?? null;
|
|
6107
|
+
const mapPath = path.join(folder, REPO_MAP_REL);
|
|
6108
|
+
const head = await gitHead(folder);
|
|
6109
|
+
const known = this.repoMaps.get(folder);
|
|
6110
|
+
if (!force && known && fs.existsSync(mapPath) && head && this.lastHead.get(folder) === head) {
|
|
6111
|
+
return known;
|
|
6112
|
+
}
|
|
6113
|
+
this.generating.add(folder);
|
|
6114
|
+
try {
|
|
6115
|
+
const info = this.generateRepoMap(folder, effective.repoMapMaxFiles);
|
|
6116
|
+
this.repoMaps.set(folder, info);
|
|
6117
|
+
if (head) this.lastHead.set(folder, head);
|
|
6118
|
+
ensureMapExcluded(folder);
|
|
6119
|
+
return info;
|
|
6120
|
+
} catch (err) {
|
|
6121
|
+
console.error("Token efficiency: repo map generation failed for", folder, err);
|
|
6122
|
+
return null;
|
|
6123
|
+
} finally {
|
|
6124
|
+
this.generating.delete(folder);
|
|
6125
|
+
}
|
|
6126
|
+
}
|
|
6127
|
+
/** Delete a folder's generated map (used when the code graph is toggled off). */
|
|
6128
|
+
removeRepoMap(folder) {
|
|
6129
|
+
this.repoMaps.delete(folder);
|
|
6130
|
+
this.lastHead.delete(folder);
|
|
6131
|
+
try {
|
|
6132
|
+
fs.rmSync(path.join(folder, REPO_MAP_REL), { force: true });
|
|
6133
|
+
} catch {
|
|
6134
|
+
}
|
|
6135
|
+
}
|
|
6136
|
+
/**
|
|
6137
|
+
* Walk the repo and produce a compact aider-style "path: symbols" map.
|
|
6138
|
+
* Regex extraction by design: dependency-free (no native tree-sitter) and
|
|
6139
|
+
* fast enough to run synchronously on spawn (caps bound the work).
|
|
6140
|
+
*/
|
|
6141
|
+
generateRepoMap(folder, maxFiles) {
|
|
6142
|
+
const ignore = /* @__PURE__ */ new Set([
|
|
6143
|
+
...this.persistence.state.settings.ignoreNames,
|
|
6144
|
+
".git",
|
|
6145
|
+
"vendor",
|
|
6146
|
+
"coverage"
|
|
6147
|
+
]);
|
|
6148
|
+
const files = [];
|
|
6149
|
+
const walk = (dir, depth) => {
|
|
6150
|
+
if (depth > 12 || files.length >= maxFiles) return;
|
|
6151
|
+
let entries;
|
|
6152
|
+
try {
|
|
6153
|
+
entries = fs.readdirSync(dir);
|
|
6154
|
+
} catch {
|
|
6155
|
+
return;
|
|
6156
|
+
}
|
|
6157
|
+
const subdirs = [];
|
|
6158
|
+
for (const entry of entries) {
|
|
6159
|
+
if (files.length >= maxFiles) return;
|
|
6160
|
+
if (ignore.has(entry) || entry.startsWith(".")) continue;
|
|
6161
|
+
const path$1 = path.join(dir, entry);
|
|
6162
|
+
let stat;
|
|
6163
|
+
try {
|
|
6164
|
+
stat = fs.statSync(path$1);
|
|
6165
|
+
} catch {
|
|
6166
|
+
continue;
|
|
6167
|
+
}
|
|
6168
|
+
if (stat.isDirectory()) {
|
|
6169
|
+
subdirs.push(path$1);
|
|
6170
|
+
} else if (stat.size <= MAP_FILE_MAX_BYTES) {
|
|
6171
|
+
const ext = entry.slice(entry.lastIndexOf(".")).toLowerCase();
|
|
6172
|
+
if (EXT_TO_PATTERNS.has(ext)) files.push(path$1);
|
|
6173
|
+
}
|
|
6174
|
+
}
|
|
6175
|
+
for (const sub of subdirs) walk(sub, depth + 1);
|
|
6176
|
+
};
|
|
6177
|
+
walk(folder, 0);
|
|
6178
|
+
const lines = [];
|
|
6179
|
+
let totalSymbols = 0;
|
|
6180
|
+
let bytes = 0;
|
|
6181
|
+
for (const path$1 of files) {
|
|
6182
|
+
let text;
|
|
6183
|
+
try {
|
|
6184
|
+
text = fs.readFileSync(path$1, "utf8");
|
|
6185
|
+
} catch {
|
|
6186
|
+
continue;
|
|
6187
|
+
}
|
|
6188
|
+
const ext = path$1.slice(path$1.lastIndexOf(".")).toLowerCase();
|
|
6189
|
+
const patterns = EXT_TO_PATTERNS.get(ext) ?? [];
|
|
6190
|
+
const symbols = [];
|
|
6191
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6192
|
+
for (const pattern of patterns) {
|
|
6193
|
+
pattern.lastIndex = 0;
|
|
6194
|
+
let m;
|
|
6195
|
+
while (m = pattern.exec(text)) {
|
|
6196
|
+
const name = m[1];
|
|
6197
|
+
if (name && !seen.has(name)) {
|
|
6198
|
+
seen.add(name);
|
|
6199
|
+
symbols.push(name);
|
|
6200
|
+
}
|
|
6201
|
+
if (seen.size > MAX_SYMBOLS_PER_FILE * 2) break;
|
|
6202
|
+
}
|
|
6203
|
+
}
|
|
6204
|
+
if (symbols.length === 0) continue;
|
|
6205
|
+
const shown = symbols.slice(0, MAX_SYMBOLS_PER_FILE);
|
|
6206
|
+
const rel = path.relative(folder, path$1).replace(/\\/g, "/");
|
|
6207
|
+
const line = rel + ": " + shown.join(", ") + (symbols.length > shown.length ? ", …" : "");
|
|
6208
|
+
if (bytes + line.length > REPO_MAP_MAX_BYTES) break;
|
|
6209
|
+
lines.push(line);
|
|
6210
|
+
bytes += line.length + 1;
|
|
6211
|
+
totalSymbols += shown.length;
|
|
6212
|
+
}
|
|
6213
|
+
const content = lines.join("\n") + "\n";
|
|
6214
|
+
const mapPath = path.join(folder, REPO_MAP_REL);
|
|
6215
|
+
fs.mkdirSync(path.dirname(mapPath), { recursive: true });
|
|
6216
|
+
fs.writeFileSync(mapPath, content, "utf8");
|
|
6217
|
+
return { generatedAt: Date.now(), files: lines.length, symbols: totalSymbols, bytes: content.length };
|
|
6218
|
+
}
|
|
6219
|
+
/** Refresh maps for sessions whose repo HEAD moved since the last poll. */
|
|
6220
|
+
async pollGitChanges() {
|
|
6221
|
+
for (const config of this.persistence.state.sessions) {
|
|
6222
|
+
const effective = this.resolveEffective(config);
|
|
6223
|
+
if (!effective.enabled || !effective.codeGraph) continue;
|
|
6224
|
+
if (!fs.existsSync(config.folder)) continue;
|
|
6225
|
+
const head = await gitHead(config.folder);
|
|
6226
|
+
if (!head) continue;
|
|
6227
|
+
const prev = this.lastHead.get(config.folder);
|
|
6228
|
+
if (prev !== head) await this.ensureRepoMap(config.folder, effective, true);
|
|
6229
|
+
}
|
|
6230
|
+
}
|
|
6231
|
+
// ---------- savings stats (logged by the hook scripts) ----------
|
|
6232
|
+
loadStats() {
|
|
6233
|
+
let stat;
|
|
6234
|
+
try {
|
|
6235
|
+
stat = fs.statSync(this.statsFile);
|
|
6236
|
+
} catch {
|
|
6237
|
+
return [];
|
|
6238
|
+
}
|
|
6239
|
+
if (this.statsCache && this.statsCache.mtimeMs === stat.mtimeMs && this.statsCache.size === stat.size) {
|
|
6240
|
+
return this.statsCache.entries;
|
|
6241
|
+
}
|
|
6242
|
+
let raw;
|
|
6243
|
+
try {
|
|
6244
|
+
raw = fs.readFileSync(this.statsFile, "utf8");
|
|
6245
|
+
} catch {
|
|
6246
|
+
return [];
|
|
6247
|
+
}
|
|
6248
|
+
if (raw.length > STATS_ROTATE_BYTES) {
|
|
6249
|
+
const tail2 = raw.slice(-1048576);
|
|
6250
|
+
raw = tail2.slice(tail2.indexOf("\n") + 1);
|
|
6251
|
+
try {
|
|
6252
|
+
fs.writeFileSync(this.statsFile, raw, "utf8");
|
|
6253
|
+
} catch {
|
|
6254
|
+
}
|
|
6255
|
+
}
|
|
6256
|
+
const entries = [];
|
|
6257
|
+
for (const line of raw.split("\n")) {
|
|
6258
|
+
if (!line.trim()) continue;
|
|
6259
|
+
try {
|
|
6260
|
+
const e = JSON.parse(line);
|
|
6261
|
+
if (e && typeof e.cwd === "string" && typeof e.kind === "string") entries.push(e);
|
|
6262
|
+
} catch {
|
|
6263
|
+
}
|
|
6264
|
+
}
|
|
6265
|
+
try {
|
|
6266
|
+
const fresh = fs.statSync(this.statsFile);
|
|
6267
|
+
this.statsCache = { mtimeMs: fresh.mtimeMs, size: fresh.size, entries };
|
|
6268
|
+
} catch {
|
|
6269
|
+
this.statsCache = null;
|
|
6270
|
+
}
|
|
6271
|
+
return entries;
|
|
6272
|
+
}
|
|
6273
|
+
/** Savings attributed to one folder ('' aggregates everything). */
|
|
6274
|
+
savingsFor(folder) {
|
|
6275
|
+
const savings = {
|
|
6276
|
+
savedTokens: 0,
|
|
6277
|
+
rtkRewrites: 0,
|
|
6278
|
+
filteredCommands: 0,
|
|
6279
|
+
blockedReads: 0
|
|
6280
|
+
};
|
|
6281
|
+
for (const e of this.loadStats()) {
|
|
6282
|
+
if (folder && !underFolder(e.cwd, folder)) continue;
|
|
6283
|
+
if (e.kind === "filter") {
|
|
6284
|
+
savings.filteredCommands++;
|
|
6285
|
+
const saved = Math.max(0, (e.orig ?? 0) - (e.out ?? 0));
|
|
6286
|
+
savings.savedTokens += Math.round(saved / 4);
|
|
6287
|
+
} else if (e.kind === "rtk") {
|
|
6288
|
+
savings.rtkRewrites++;
|
|
6289
|
+
} else if (e.kind === "blocked-read") {
|
|
6290
|
+
savings.blockedReads++;
|
|
6291
|
+
savings.savedTokens += Math.min(Math.round((e.bytes ?? 0) / 4), BLOCKED_READ_MAX_TOKENS);
|
|
6292
|
+
}
|
|
6293
|
+
}
|
|
6294
|
+
return savings;
|
|
6295
|
+
}
|
|
6296
|
+
// ---------- status & settings mutation (IPC surface) ----------
|
|
6297
|
+
status(sessionId) {
|
|
6298
|
+
const config = this.persistence.state.sessions.find((s) => s.id === sessionId);
|
|
6299
|
+
if (!config) return null;
|
|
6300
|
+
const settings = this.persistence.state.settings;
|
|
6301
|
+
const effective = this.resolveEffective(config);
|
|
6302
|
+
const applied = this.applied.get(sessionId) ?? null;
|
|
6303
|
+
const tools = this.detectTools();
|
|
6304
|
+
return {
|
|
6305
|
+
effective,
|
|
6306
|
+
repoOverride: settings.tokenEfficiencyRepoOverrides[this.repoKeyOf(config)] ?? null,
|
|
6307
|
+
sessionOverride: config.tokenEfficiency ?? null,
|
|
6308
|
+
rtk: tools.rtk,
|
|
6309
|
+
nodeFound: tools.nodeFound,
|
|
6310
|
+
applied,
|
|
6311
|
+
pendingRestart: applied !== null && JSON.stringify(applied) !== JSON.stringify(effective),
|
|
6312
|
+
repoMap: this.repoMaps.get(config.folder) ?? null,
|
|
6313
|
+
savings: this.savingsFor(config.folder)
|
|
6314
|
+
};
|
|
6315
|
+
}
|
|
6316
|
+
/** Persist the global config and re-materialize every session's repo. */
|
|
6317
|
+
saveGlobal(config) {
|
|
6318
|
+
this.persistence.state.settings.tokenEfficiency = config;
|
|
6319
|
+
this.persistence.scheduleSave();
|
|
6320
|
+
this.reapplyAll();
|
|
6321
|
+
}
|
|
6322
|
+
/** Set/clear the override for a session's repo, then re-materialize that repo. */
|
|
6323
|
+
setRepoOverride(sessionId, override) {
|
|
6324
|
+
const config = this.persistence.state.sessions.find((s) => s.id === sessionId);
|
|
6325
|
+
if (!config) return;
|
|
6326
|
+
const key = this.repoKeyOf(config);
|
|
6327
|
+
const overrides = this.persistence.state.settings.tokenEfficiencyRepoOverrides;
|
|
6328
|
+
const cleaned = defined(override);
|
|
6329
|
+
if (override && Object.keys(cleaned).length > 0) overrides[key] = cleaned;
|
|
6330
|
+
else delete overrides[key];
|
|
6331
|
+
this.persistence.scheduleSave();
|
|
6332
|
+
for (const s of this.persistence.state.sessions) {
|
|
6333
|
+
if (this.repoKeyOf(s) === key && s.terminals.some((t) => t.kind === "claude")) this.apply(s);
|
|
6334
|
+
}
|
|
6335
|
+
}
|
|
6336
|
+
/** Set/clear one session's own override, then re-materialize it. */
|
|
6337
|
+
setSessionOverride(sessionId, override) {
|
|
6338
|
+
const config = this.persistence.state.sessions.find((s) => s.id === sessionId);
|
|
6339
|
+
if (!config) return;
|
|
6340
|
+
const cleaned = defined(override);
|
|
6341
|
+
config.tokenEfficiency = override && Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
6342
|
+
this.persistence.scheduleSave();
|
|
6343
|
+
if (config.terminals.some((t) => t.kind === "claude")) this.apply(config);
|
|
6344
|
+
}
|
|
6345
|
+
/** Regenerate a session's repo map right now. */
|
|
6346
|
+
async refreshRepoMap(sessionId) {
|
|
6347
|
+
const config = this.persistence.state.sessions.find((s) => s.id === sessionId);
|
|
6348
|
+
if (!config || !fs.existsSync(config.folder)) return null;
|
|
6349
|
+
const effective = this.resolveEffective(config);
|
|
6350
|
+
if (!effective.enabled || !effective.codeGraph) return null;
|
|
6351
|
+
return this.ensureRepoMap(config.folder, effective, true);
|
|
6352
|
+
}
|
|
6353
|
+
/** Re-materialize every session that hosts a claude terminal. */
|
|
6354
|
+
reapplyAll() {
|
|
6355
|
+
for (const config of this.persistence.state.sessions) {
|
|
6356
|
+
if (config.terminals.some((t) => t.kind === "claude")) this.apply(config);
|
|
6357
|
+
}
|
|
6358
|
+
}
|
|
6359
|
+
}
|
|
5190
6360
|
let win = null;
|
|
5191
6361
|
const getWin = () => win;
|
|
5192
6362
|
const persistence = new Persistence();
|
|
@@ -5253,7 +6423,8 @@ if (!gotLock) {
|
|
|
5253
6423
|
(sessionId, events) => getWin()?.webContents.send("fs:events", sessionId, events),
|
|
5254
6424
|
() => persistence.state.settings.ignoreNames
|
|
5255
6425
|
);
|
|
5256
|
-
const
|
|
6426
|
+
const tokenEff = new TokenEfficiencyService(persistence);
|
|
6427
|
+
const sessions = new SessionManager(persistence, fsService, tokenEff, getWin);
|
|
5257
6428
|
const sentinels = new SentinelService(persistence, getWin);
|
|
5258
6429
|
const features = new FeatureService(persistence, sessions);
|
|
5259
6430
|
const autoExpand = new AutoExpandService(persistence, features, getWin);
|
|
@@ -5268,6 +6439,7 @@ if (!gotLock) {
|
|
|
5268
6439
|
autoExpand,
|
|
5269
6440
|
conductor,
|
|
5270
6441
|
factory,
|
|
6442
|
+
tokenEff,
|
|
5271
6443
|
getWin
|
|
5272
6444
|
);
|
|
5273
6445
|
createWindow();
|
|
@@ -5275,10 +6447,12 @@ if (!gotLock) {
|
|
|
5275
6447
|
sessions.startWatchdog();
|
|
5276
6448
|
sentinels.start();
|
|
5277
6449
|
autoExpand.start();
|
|
6450
|
+
tokenEff.start();
|
|
5278
6451
|
electron.app.on("activate", () => {
|
|
5279
6452
|
if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
5280
6453
|
});
|
|
5281
6454
|
electron.app.on("before-quit", () => {
|
|
6455
|
+
tokenEff.dispose();
|
|
5282
6456
|
factory.dispose();
|
|
5283
6457
|
conductor.dispose();
|
|
5284
6458
|
autoExpand.dispose();
|