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.
Files changed (28) hide show
  1. package/out/main/index.js +1248 -74
  2. package/out/preload/index.js +21 -2
  3. package/out/renderer/assets/{index-BYr39Do3.js → index-3GYmITDo.js} +2 -2
  4. package/out/renderer/assets/{index-t8kJBumF.js → index-BTPlkF9b.js} +2 -2
  5. package/out/renderer/assets/{index-BJ7HHtlW.css → index-BW8lIWcc.css} +4514 -3797
  6. package/out/renderer/assets/{index-QPm3UFYR.js → index-BuCQKc-Z.js} +2 -2
  7. package/out/renderer/assets/{index-CXmk7jHk.js → index-C27Lfjw4.js} +5 -5
  8. package/out/renderer/assets/{index-BLtm0KYA.js → index-C3tXFLVi.js} +2 -2
  9. package/out/renderer/assets/{index-RgZE5T_I.js → index-C9LaSnRB.js} +2 -2
  10. package/out/renderer/assets/{index-RGiHNShF.js → index-CA5CvYDB.js} +1 -1
  11. package/out/renderer/assets/{index-BQE9vCdv.js → index-CIscupyH.js} +5 -5
  12. package/out/renderer/assets/{index-BLk1t3tc.js → index-CYds92PN.js} +3 -3
  13. package/out/renderer/assets/{index-C7i4WyfA.js → index-CngVaxBn.js} +5 -5
  14. package/out/renderer/assets/{index-DkFO-IXC.js → index-CrDDqySQ.js} +5 -5
  15. package/out/renderer/assets/{index-Da2gJ_Wd.js → index-D14Kkf05.js} +2 -2
  16. package/out/renderer/assets/{index-2SQReYbL.js → index-D2J11DOI.js} +5 -5
  17. package/out/renderer/assets/{index-Btn996gP.js → index-D48Zxs0r.js} +2 -2
  18. package/out/renderer/assets/{index-BPgGZ4iY.js → index-DLIiZzc_.js} +2 -2
  19. package/out/renderer/assets/{index-B4G_DF7l.js → index-DSDf1IPU.js} +2 -2
  20. package/out/renderer/assets/{index-Ba0sUbhl.js → index-DTKHKVCm.js} +1420 -212
  21. package/out/renderer/assets/{index-BewSkGJT.js → index-DVz1TdHX.js} +4 -4
  22. package/out/renderer/assets/{index-BnId7J8H.js → index-DYixubwQ.js} +2 -2
  23. package/out/renderer/assets/{index-Dw7p19YD.js → index-DypnZCas.js} +3 -3
  24. package/out/renderer/assets/{index-DqrDpNqn.js → index-H1mXv84m.js} +2 -2
  25. package/out/renderer/assets/{index-CyQp9UDR.js → index-dBdOx5at.js} +5 -5
  26. package/out/renderer/assets/{index-CW9SAu99.js → index-tMx4AmRy.js} +2 -2
  27. package/out/renderer/index.html +2 -2
  28. 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
- if (!trimmed || this.busy) return;
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
- /** Approve and run one proposed action. */
1476
- async approve(messageId, actionId) {
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
- await this.runAction(action);
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 task = await this.features.implement(feature.id);
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: String(a.baseBranch ?? "").trim(),
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
- return `Spun task “${task.config.name}” on branch ${branch}.`;
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
- return `${m.role.toUpperCase()}: ${m.text}${acts}`;
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()/a new run can cancel it. */
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
- candidate.status = "error";
2298
- candidate.result = err.message || String(err);
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: { ...DEFAULT_SETTINGS, ...raw.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
- claudeArgs: [],
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 merge = await this.mergeWorktree(sessionId, true);
4623
- event.ok = merge.ok;
4624
- event.conflict = merge.conflict;
4625
- event.output = merge.output;
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 sessions = new SessionManager(persistence, fsService, getWin);
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();