claude-maestro 0.1.17 → 0.1.19

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 +2172 -126
  2. package/out/preload/index.js +31 -2
  3. package/out/renderer/assets/{index-BeK4bVVW.js → index-1Z03T0zz.js} +2 -2
  4. package/out/renderer/assets/{index-CseejV6v.js → index-9AHdXE8U.js} +2 -2
  5. package/out/renderer/assets/{index-e-2XNYKm.js → index-B59uuZRU.js} +4 -4
  6. package/out/renderer/assets/{index-DKiZc23E.js → index-Bg4ondS2.js} +2 -2
  7. package/out/renderer/assets/{index-DGta923C.js → index-BkOzhsuz.js} +1 -1
  8. package/out/renderer/assets/{index-S5NYpQrX.js → index-C0rsWi9C.js} +2 -2
  9. package/out/renderer/assets/{index-B_7ahRbo.js → index-C479DZmL.js} +5 -5
  10. package/out/renderer/assets/{index-35ZMn6bu.js → index-CNNAMsV1.js} +2 -2
  11. package/out/renderer/assets/{index-C8oSXBxe.js → index-CTxGDYbk.js} +2310 -217
  12. package/out/renderer/assets/{index-BlsLZJSj.js → index-CVWvgy2Y.js} +2 -2
  13. package/out/renderer/assets/{index-CjfG7YiA.js → index-CWk6CwGd.js} +3 -3
  14. package/out/renderer/assets/{index-DaIzPJQm.js → index-CXeHg_Qc.js} +2 -2
  15. package/out/renderer/assets/{index-B9oOob3n.js → index-CZP8wVw-.js} +2 -2
  16. package/out/renderer/assets/{index-qbtkuRq_.js → index-CoyUYEik.js} +3 -3
  17. package/out/renderer/assets/{index-DZE8-R5I.js → index-Cq5xQaOf.js} +5 -5
  18. package/out/renderer/assets/{index-BjEH10pM.js → index-CuHjzw7d.js} +5 -5
  19. package/out/renderer/assets/{index-CWJQ6syz.js → index-D9GPva9-.js} +5 -5
  20. package/out/renderer/assets/{index-DiOWreDY.js → index-DI2ly48w.js} +2 -2
  21. package/out/renderer/assets/{index-BdfeMjZM.js → index-DJwKAmOm.js} +2 -2
  22. package/out/renderer/assets/{index-fIdb4ub2.css → index-Dgaj6c_K.css} +998 -1
  23. package/out/renderer/assets/{index-BnwIPayX.js → index-Dhxn3JIv.js} +2 -2
  24. package/out/renderer/assets/{index-CbwbiCFx.js → index-JMVyecfQ.js} +5 -5
  25. package/out/renderer/assets/{index-DmNNJ1HK.js → index-LW-gCnC-.js} +2 -2
  26. package/out/renderer/assets/{index-vek4mKC3.js → index-jAA5WJm3.js} +5 -5
  27. package/out/renderer/index.html +2 -2
  28. package/package.json +1 -1
package/out/main/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
  const electron = require("electron");
3
3
  const path = require("path");
4
- const crypto = require("crypto");
5
4
  const fs = require("fs");
6
- const child_process = require("child_process");
7
5
  const os = require("os");
6
+ const crypto = require("crypto");
7
+ const child_process = require("child_process");
8
8
  const pty = require("node-pty");
9
9
  const chokidar = require("chokidar");
10
10
  const promises = require("fs/promises");
@@ -25,6 +25,278 @@ function _interopNamespaceDefault(e) {
25
25
  return Object.freeze(n);
26
26
  }
27
27
  const pty__namespace = /* @__PURE__ */ _interopNamespaceDefault(pty);
28
+ const USER_AGENTS_DIR = path.join(os.homedir(), ".claude", "agents");
29
+ const WATCH_DEBOUNCE_MS = 400;
30
+ class AgentRegistryService {
31
+ constructor(persistence2, getWin2) {
32
+ this.persistence = persistence2;
33
+ this.getWin = getWin2;
34
+ }
35
+ watchers = /* @__PURE__ */ new Map();
36
+ debounce = null;
37
+ /** Paths the renderer may read/reveal — everything the last snapshot listed. */
38
+ knownPaths = /* @__PURE__ */ new Set();
39
+ disposed = false;
40
+ dispose() {
41
+ this.disposed = true;
42
+ if (this.debounce) clearTimeout(this.debounce);
43
+ for (const w of this.watchers.values()) w.close();
44
+ this.watchers.clear();
45
+ }
46
+ registryPath() {
47
+ return this.persistence.state.settings.agentRegistryPath.trim();
48
+ }
49
+ /** Unique session repo folders (project-local agents may live in each). */
50
+ projectDirs() {
51
+ return [...new Set(this.persistence.state.sessions.map((s) => s.folder).filter(Boolean))];
52
+ }
53
+ /** Build a fresh snapshot and re-arm the watchers for the dirs it covers. */
54
+ snapshot() {
55
+ const registryPath = this.registryPath();
56
+ const registry = readRegistry(registryPath);
57
+ const agents = scanAgentsDir(USER_AGENTS_DIR, "user", null);
58
+ for (const dir of this.projectDirs()) {
59
+ agents.push(...scanAgentsDir(path.join(dir, ".claude", "agents"), "project", dir));
60
+ }
61
+ agents.sort((a, b) => a.name.localeCompare(b.name));
62
+ const byName = /* @__PURE__ */ new Map();
63
+ for (const e of registry.entries) byName.set(e.name.toLowerCase(), e);
64
+ const matched = /* @__PURE__ */ new Set();
65
+ for (const agent of agents) {
66
+ const entry = byName.get(agent.name.toLowerCase());
67
+ if (entry) {
68
+ agent.registry = entry;
69
+ matched.add(entry);
70
+ }
71
+ }
72
+ const missing = registry.entries.filter((e) => e.fileMissing && !matched.has(e));
73
+ const registryDir = path.dirname(registryPath);
74
+ const snapshot = {
75
+ agents,
76
+ missing,
77
+ registryPath,
78
+ registryError: registry.error,
79
+ registryVersion: registry.version,
80
+ registryUpdated: registry.updated,
81
+ factoryRunning: fs.existsSync(path.join(registryDir, ".factory.lock"))
82
+ };
83
+ this.knownPaths = /* @__PURE__ */ new Set();
84
+ for (const a of agents) this.knownPaths.add(a.filePath);
85
+ for (const e of registry.entries) if (e.filePath) this.knownPaths.add(e.filePath);
86
+ this.armWatchers(registryDir);
87
+ return snapshot;
88
+ }
89
+ /** Re-snapshot now and push it to the renderer (manual refresh / settings change). */
90
+ refresh() {
91
+ const snapshot = this.snapshot();
92
+ this.getWin()?.webContents.send("agents:changed", snapshot);
93
+ return snapshot;
94
+ }
95
+ /** Read an agent file the last snapshot listed (null when unknown/unreadable). */
96
+ readAgentFile(filePath) {
97
+ if (this.knownPaths.size === 0) this.snapshot();
98
+ if (!this.knownPaths.has(filePath)) return null;
99
+ try {
100
+ return fs.readFileSync(filePath, "utf8");
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+ /** Reveal an agent file from the last snapshot in the OS file manager. */
106
+ revealAgentFile(filePath) {
107
+ if (this.knownPaths.has(filePath) && fs.existsSync(filePath)) electron.shell.showItemInFolder(filePath);
108
+ }
109
+ // ---------- file watching ----------
110
+ /**
111
+ * Watch the user agents dir, every project agents dir and the registry dir
112
+ * (the latter covers registry.json writes AND .factory.lock create/delete).
113
+ * Idempotent: only the delta of dirs is (un)watched.
114
+ */
115
+ armWatchers(registryDir) {
116
+ if (this.disposed) return;
117
+ const wanted = /* @__PURE__ */ new Set();
118
+ if (fs.existsSync(USER_AGENTS_DIR)) wanted.add(USER_AGENTS_DIR);
119
+ if (fs.existsSync(registryDir)) wanted.add(registryDir);
120
+ for (const dir of this.projectDirs()) {
121
+ const agentsDir = path.join(dir, ".claude", "agents");
122
+ if (fs.existsSync(agentsDir)) wanted.add(agentsDir);
123
+ }
124
+ for (const [dir, watcher] of this.watchers) {
125
+ if (!wanted.has(dir)) {
126
+ watcher.close();
127
+ this.watchers.delete(dir);
128
+ }
129
+ }
130
+ for (const dir of wanted) {
131
+ if (this.watchers.has(dir)) continue;
132
+ try {
133
+ const watcher = fs.watch(dir, () => this.scheduleRefresh());
134
+ watcher.on("error", () => {
135
+ watcher.close();
136
+ this.watchers.delete(dir);
137
+ });
138
+ this.watchers.set(dir, watcher);
139
+ } catch {
140
+ }
141
+ }
142
+ }
143
+ scheduleRefresh() {
144
+ if (this.disposed) return;
145
+ if (this.debounce) clearTimeout(this.debounce);
146
+ this.debounce = setTimeout(() => {
147
+ this.debounce = null;
148
+ this.refresh();
149
+ }, WATCH_DEBOUNCE_MS);
150
+ }
151
+ }
152
+ function parseAgentFrontmatter(md) {
153
+ const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(md);
154
+ if (!m) return {};
155
+ const out = {};
156
+ const lines = m[1].split(/\r?\n/);
157
+ for (let i = 0; i < lines.length; i++) {
158
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
159
+ if (!kv) continue;
160
+ const key = kv[1];
161
+ let value = kv[2].trim();
162
+ if (value === "|" || value === ">" || value === "|-" || value === ">-") {
163
+ const block = [];
164
+ while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
165
+ block.push(lines[++i].trim());
166
+ }
167
+ value = block.join(" ").trim();
168
+ } else if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
169
+ value = value.slice(1, -1);
170
+ }
171
+ if (key === "name") out.name = value;
172
+ else if (key === "description") out.description = value;
173
+ else if (key === "model") out.model = value;
174
+ }
175
+ return out;
176
+ }
177
+ function scanAgentsDir(dir, scope, projectDir) {
178
+ if (!fs.existsSync(dir)) return [];
179
+ let entries;
180
+ try {
181
+ entries = fs.readdirSync(dir);
182
+ } catch {
183
+ return [];
184
+ }
185
+ const out = [];
186
+ for (const entry of entries) {
187
+ if (!entry.endsWith(".md")) continue;
188
+ const filePath = path.join(dir, entry);
189
+ const base = entry.replace(/\.md$/, "");
190
+ let fm = {};
191
+ try {
192
+ fm = parseAgentFrontmatter(fs.readFileSync(filePath, "utf8"));
193
+ } catch {
194
+ }
195
+ out.push({
196
+ name: fm.name?.trim() || base,
197
+ description: fm.description ?? "",
198
+ model: fm.model?.trim() || null,
199
+ scope,
200
+ projectDir,
201
+ filePath,
202
+ registry: null
203
+ });
204
+ }
205
+ return out;
206
+ }
207
+ function readRegistry(registryPath) {
208
+ if (!registryPath) return { entries: [], error: "No registry path configured.", version: null, updated: null };
209
+ if (!fs.existsSync(registryPath)) {
210
+ return { entries: [], error: `Registry not found: ${registryPath}`, version: null, updated: null };
211
+ }
212
+ let parsed;
213
+ try {
214
+ parsed = JSON.parse(fs.readFileSync(registryPath, "utf8"));
215
+ } catch (err) {
216
+ return {
217
+ entries: [],
218
+ error: `Registry unreadable: ${err.message || String(err)}`,
219
+ version: null,
220
+ updated: null
221
+ };
222
+ }
223
+ const root2 = parsed ?? {};
224
+ const meta = root2._meta ?? {};
225
+ const rawAgents = Array.isArray(root2.agents) ? root2.agents : [];
226
+ const entries = [];
227
+ for (const raw of rawAgents) {
228
+ if (!raw || typeof raw !== "object") continue;
229
+ const entry = parseEntry(raw, registryPath);
230
+ if (entry) entries.push(entry);
231
+ }
232
+ return {
233
+ entries,
234
+ error: null,
235
+ version: typeof meta.version === "string" ? meta.version : null,
236
+ updated: typeof meta.last_updated === "string" ? meta.last_updated : null
237
+ };
238
+ }
239
+ function parseEntry(r, registryPath) {
240
+ const name = String(r.name ?? "").trim();
241
+ if (!name) return null;
242
+ const filePath = resolveEntryPath(r.file_path, registryPath);
243
+ return {
244
+ name,
245
+ filePath,
246
+ type: optString(r.type),
247
+ status: optString(r.status),
248
+ archetype: optString(r.archetype),
249
+ model: optString(r.model),
250
+ scope: optString(r.scope),
251
+ description: String(r.description ?? "").trim(),
252
+ topics: toStringArray$1(r.topics),
253
+ keywords: toStringArray$1(r.keywords),
254
+ relatedAgents: toStringArray$1(r.related_agents),
255
+ confluencePages: [
256
+ .../* @__PURE__ */ new Set([...toStringArray$1(r.confluence_source), ...toStringArray$1(r.confluence_pages)])
257
+ ],
258
+ sourceVerified: r.source_verified === true,
259
+ githubRepos: toGithubRepos(r.github_repos),
260
+ githubVerified: r.github_verified === true,
261
+ knowledgeNotes: toStringArray$1(r.knowledge_note),
262
+ factoryMade: typeof r.factory_made === "boolean" ? r.factory_made : null,
263
+ created: optString(r.created),
264
+ lastUpdated: optString(r.last_updated),
265
+ fileMissing: filePath !== null && !fs.existsSync(filePath)
266
+ };
267
+ }
268
+ function resolveEntryPath(raw, registryPath) {
269
+ const fp = String(raw ?? "").trim();
270
+ if (!fp) return null;
271
+ if (path.isAbsolute(fp)) return fp;
272
+ const registryDir = path.dirname(registryPath);
273
+ const fromRepoRoot = path.resolve(path.dirname(registryDir), fp);
274
+ if (fs.existsSync(fromRepoRoot)) return fromRepoRoot;
275
+ const fromRegistryDir = path.resolve(registryDir, fp);
276
+ return fs.existsSync(fromRegistryDir) ? fromRegistryDir : fromRepoRoot;
277
+ }
278
+ function optString(v) {
279
+ if (typeof v !== "string") return null;
280
+ const s = v.trim();
281
+ return s || null;
282
+ }
283
+ function toStringArray$1(v) {
284
+ if (typeof v === "string") return v.trim() ? [v.trim()] : [];
285
+ if (!Array.isArray(v)) return [];
286
+ return [...new Set(v.map((x) => String(x).trim()).filter(Boolean))];
287
+ }
288
+ function toGithubRepos(v) {
289
+ if (!Array.isArray(v)) return [];
290
+ const out = [];
291
+ for (const raw of v) {
292
+ if (!raw || typeof raw !== "object") continue;
293
+ const r = raw;
294
+ const repo = String(r.repo ?? "").trim();
295
+ if (!repo) continue;
296
+ out.push({ repo, ref: optString(r.ref), paths: toStringArray$1(r.paths) });
297
+ }
298
+ return out;
299
+ }
28
300
  function git(cwd, args, env) {
29
301
  return new Promise((resolve2, reject) => {
30
302
  child_process.execFile(
@@ -70,6 +342,29 @@ async function worktreeInfo(folder) {
70
342
  return { isRepo: false, repoRoot: null, branch: null };
71
343
  }
72
344
  }
345
+ async function listBranches(folder) {
346
+ try {
347
+ const [refs, head] = await Promise.all([
348
+ git(folder, ["for-each-ref", "refs/heads", "--sort=refname", "--format=%(refname:short)"]),
349
+ git(folder, ["rev-parse", "--abbrev-ref", "HEAD"])
350
+ ]);
351
+ if (refs.code !== 0) return { branches: [], current: null, defaultBranch: null };
352
+ const branches = refs.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
353
+ const current = head.code === 0 && head.stdout.trim() !== "HEAD" ? head.stdout.trim() : null;
354
+ let defaultBranch = null;
355
+ const originHead = await git(folder, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
356
+ if (originHead.code === 0) {
357
+ const name = originHead.stdout.trim().replace(/^origin\//, "");
358
+ if (branches.includes(name)) defaultBranch = name;
359
+ }
360
+ if (!defaultBranch) {
361
+ defaultBranch = ["main", "master"].find((b) => branches.includes(b)) ?? (current && branches.includes(current) ? current : branches[0] ?? null);
362
+ }
363
+ return { branches, current, defaultBranch };
364
+ } catch {
365
+ return { branches: [], current: null, defaultBranch: null };
366
+ }
367
+ }
73
368
  async function gitInit(folder) {
74
369
  const init = await git(folder, ["init"]);
75
370
  if (init.code !== 0) return init;
@@ -697,21 +992,21 @@ class StatusDetector {
697
992
  }
698
993
  }
699
994
  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" });
995
+ const IS_WIN$1 = process.platform === "win32";
996
+ function which$1(name) {
997
+ const out = IS_WIN$1 ? child_process.spawnSync("where.exe", [name], { encoding: "utf8" }) : child_process.spawnSync("which", ["-a", name], { encoding: "utf8" });
703
998
  if (out.status !== 0 || !out.stdout) return [];
704
999
  return out.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
705
1000
  }
706
1001
  const cache = /* @__PURE__ */ new Map();
707
1002
  function resolveClaude() {
708
- const candidates = which("claude");
709
- const home = IS_WIN ? process.env.USERPROFILE : process.env.HOME;
1003
+ const candidates = which$1("claude");
1004
+ const home = IS_WIN$1 ? process.env.USERPROFILE : process.env.HOME;
710
1005
  if (home) {
711
- const localBin = path.join(home, ".local", "bin", IS_WIN ? "claude.exe" : "claude");
1006
+ const localBin = path.join(home, ".local", "bin", IS_WIN$1 ? "claude.exe" : "claude");
712
1007
  if (fs.existsSync(localBin)) candidates.push(localBin);
713
1008
  }
714
- if (!IS_WIN) {
1009
+ if (!IS_WIN$1) {
715
1010
  return candidates[0] ? { file: candidates[0], argsPrefix: [] } : null;
716
1011
  }
717
1012
  const exe = candidates.find((c) => c.toLowerCase().endsWith(".exe"));
@@ -721,19 +1016,19 @@ function resolveClaude() {
721
1016
  return null;
722
1017
  }
723
1018
  function resolvePowershell() {
724
- const pwsh = which(IS_WIN ? "pwsh.exe" : "pwsh")[0] ?? which("pwsh")[0];
1019
+ const pwsh = which$1(IS_WIN$1 ? "pwsh.exe" : "pwsh")[0] ?? which$1("pwsh")[0];
725
1020
  if (pwsh) return { file: pwsh, argsPrefix: ["-NoLogo"] };
726
- return IS_WIN ? { file: "powershell.exe", argsPrefix: ["-NoLogo"] } : null;
1021
+ return IS_WIN$1 ? { file: "powershell.exe", argsPrefix: ["-NoLogo"] } : null;
727
1022
  }
728
1023
  function resolveCmd() {
729
- return IS_WIN ? { file: process.env.ComSpec ?? "cmd.exe", argsPrefix: [] } : null;
1024
+ return IS_WIN$1 ? { file: process.env.ComSpec ?? "cmd.exe", argsPrefix: [] } : null;
730
1025
  }
731
1026
  function resolveBash() {
732
- if (!IS_WIN) {
733
- const p = which("bash")[0] ?? (fs.existsSync("/bin/bash") ? "/bin/bash" : null);
1027
+ if (!IS_WIN$1) {
1028
+ const p = which$1("bash")[0] ?? (fs.existsSync("/bin/bash") ? "/bin/bash" : null);
734
1029
  return p ? { file: p, argsPrefix: ["-i", "-l"] } : null;
735
1030
  }
736
- const onPath = which("bash.exe")[0] ?? which("bash")[0];
1031
+ const onPath = which$1("bash.exe")[0] ?? which$1("bash")[0];
737
1032
  if (onPath) return { file: onPath, argsPrefix: ["-i", "-l"] };
738
1033
  const roots = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]].filter(Boolean);
739
1034
  for (const root2 of roots) {
@@ -745,8 +1040,8 @@ function resolveBash() {
745
1040
  return null;
746
1041
  }
747
1042
  function resolveZsh() {
748
- if (IS_WIN) return null;
749
- const p = which("zsh")[0] ?? (fs.existsSync("/bin/zsh") ? "/bin/zsh" : null);
1043
+ if (IS_WIN$1) return null;
1044
+ const p = which$1("zsh")[0] ?? (fs.existsSync("/bin/zsh") ? "/bin/zsh" : null);
750
1045
  return p ? { file: p, argsPrefix: ["-i", "-l"] } : null;
751
1046
  }
752
1047
  function resolveKind(kind) {
@@ -774,17 +1069,18 @@ function resolveKind(kind) {
774
1069
  }
775
1070
  const KIND_MISSING = {
776
1071
  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.",
1072
+ bash: IS_WIN$1 ? "bash not found.\r\nInstall Git for Windows to get Git Bash." : "bash not found on PATH.",
778
1073
  zsh: "zsh not found on PATH.",
779
1074
  powershell: "PowerShell (pwsh) not found on PATH.",
780
1075
  cmd: "cmd.exe is only available on Windows."
781
1076
  };
782
1077
  class PtySession {
783
- constructor(config, folder, cb, sessionEnv = {}) {
1078
+ constructor(config, folder, cb, sessionEnv = {}, dropEnv = []) {
784
1079
  this.config = config;
785
1080
  this.folder = folder;
786
1081
  this.cb = cb;
787
1082
  this.sessionEnv = sessionEnv;
1083
+ this.dropEnv = dropEnv;
788
1084
  this.detector = new StatusDetector(
789
1085
  (s) => cb.onStatus(config.id, s),
790
1086
  config.kind !== "claude"
@@ -796,6 +1092,8 @@ class PtySession {
796
1092
  attached = false;
797
1093
  detector;
798
1094
  exitCode = null;
1095
+ /** Total chars of live process output this run (token-estimate feed). */
1096
+ outputChars = 0;
799
1097
  get pid() {
800
1098
  return this.proc?.pid ?? null;
801
1099
  }
@@ -826,6 +1124,9 @@ class PtySession {
826
1124
  for (const [k, v] of Object.entries(this.sessionEnv)) {
827
1125
  if (k.trim()) env[k] = v;
828
1126
  }
1127
+ for (const k of this.dropEnv) {
1128
+ if (!(k in this.sessionEnv)) delete env[k];
1129
+ }
829
1130
  try {
830
1131
  this.proc = pty__namespace.spawn(cmd.file, args, {
831
1132
  cols: 120,
@@ -905,6 +1206,7 @@ class PtySession {
905
1206
  handleData(data) {
906
1207
  this.chunks.push(data);
907
1208
  this.bufferedBytes += data.length;
1209
+ this.outputChars += data.length;
908
1210
  while (this.bufferedBytes > RING_BUFFER_BYTES && this.chunks.length > 1) {
909
1211
  this.bufferedBytes -= this.chunks[0].length;
910
1212
  this.chunks.shift();
@@ -1005,7 +1307,7 @@ function extractJson(text) {
1005
1307
  return null;
1006
1308
  }
1007
1309
  }
1008
- const TICK_MS$1 = 3e4;
1310
+ const TICK_MS$2 = 3e4;
1009
1311
  const AGENT_TIMEOUT_MS = 5 * 6e4;
1010
1312
  const MAX_RUNS_PER_SESSION$1 = 30;
1011
1313
  const IDEA_COUNT = 4;
@@ -1034,7 +1336,7 @@ class AutoExpandService {
1034
1336
  runs = /* @__PURE__ */ new Map();
1035
1337
  start() {
1036
1338
  if (this.timer) return;
1037
- this.timer = setInterval(() => this.tick(), TICK_MS$1);
1339
+ this.timer = setInterval(() => this.tick(), TICK_MS$2);
1038
1340
  this.tick();
1039
1341
  }
1040
1342
  dispose() {
@@ -1401,6 +1703,14 @@ class ConductorService {
1401
1703
  /** The in-flight planner child, so dispose()/a new turn can cancel it. */
1402
1704
  inFlight = null;
1403
1705
  busy = false;
1706
+ /** Observer fired after each completed (non-error) turn — wired to the Factory's
1707
+ * self-growth detector in index.ts. A post-construction setter avoids a
1708
+ * constructor cycle (conductor is built before the factory). */
1709
+ turnCompleteCb = null;
1710
+ /** Register an observer notified after each successful turn (e.g. the Factory). */
1711
+ onTurnComplete(cb) {
1712
+ this.turnCompleteCb = cb;
1713
+ }
1404
1714
  list() {
1405
1715
  return this.messages;
1406
1716
  }
@@ -1426,17 +1736,25 @@ class ConductorService {
1426
1736
  * `tagSessionId` focuses the turn on one session: the planner runs in that
1427
1737
  * repo, sees only that session's state, and defaults its actions to it. Null
1428
1738
  * (or an id that no longer exists) keeps the cross-repo conductor behaviour.
1739
+ *
1740
+ * `images` are files already saved by the conductor attach IPC; the planner
1741
+ * is told to Read each one (its Read tool renders images), so a screenshot
1742
+ * pasted into the chat is actually seen, not just mentioned.
1429
1743
  */
1430
- async send(text, tagSessionId = null) {
1744
+ async send(text, tagSessionId = null, images = []) {
1431
1745
  const trimmed = text.trim();
1432
- if (!trimmed || this.busy) return;
1746
+ const attached = (images ?? []).filter(
1747
+ (i) => i && typeof i.path === "string" && fs.existsSync(i.path)
1748
+ );
1749
+ if (!trimmed && attached.length === 0 || this.busy) return;
1433
1750
  this.busy = true;
1434
1751
  const focusId = tagSessionId && this.sessions.getConfig(tagSessionId) ? tagSessionId : null;
1435
1752
  const userMsg = {
1436
1753
  id: crypto.randomUUID(),
1437
1754
  role: "user",
1438
1755
  text: trimmed,
1439
- at: Date.now()
1756
+ at: Date.now(),
1757
+ ...attached.length > 0 ? { images: attached } : {}
1440
1758
  };
1441
1759
  const assistantMsg = {
1442
1760
  id: crypto.randomUUID(),
@@ -1449,7 +1767,7 @@ class ConductorService {
1449
1767
  this.persistAndBroadcast();
1450
1768
  try {
1451
1769
  const snapshot = await this.buildSnapshot(focusId);
1452
- const prompt = this.buildPrompt(snapshot, userMsg.text, focusId);
1770
+ const prompt = this.buildPrompt(snapshot, userMsg.text, focusId, attached);
1453
1771
  const cwd = this.plannerCwd(focusId);
1454
1772
  const out = await runHeadlessClaude({
1455
1773
  cwd,
@@ -1470,13 +1788,29 @@ class ConductorService {
1470
1788
  this.inFlight = null;
1471
1789
  this.busy = false;
1472
1790
  this.persistAndBroadcast();
1791
+ if (!assistantMsg.error) {
1792
+ try {
1793
+ this.turnCompleteCb?.(this.messages);
1794
+ } catch {
1795
+ }
1796
+ }
1473
1797
  }
1474
1798
  }
1475
- /** Approve and run one proposed action. */
1476
- async approve(messageId, actionId) {
1799
+ /**
1800
+ * Approve and run one proposed action. For task-creating actions, `options`
1801
+ * carries the approval card's choices (base branch, model, PR/auto-merge);
1802
+ * they are applied to the created task and persisted as that repo's defaults
1803
+ * for the next proposal.
1804
+ */
1805
+ async approve(messageId, actionId, options) {
1477
1806
  const action = this.findAction(messageId, actionId);
1478
1807
  if (!action || action.status !== "proposed") return;
1479
- await this.runAction(action);
1808
+ if (options) this.saveTaskDefaults(action, options);
1809
+ await this.runAction(action, options);
1810
+ }
1811
+ /** Persisted per-repo task-card defaults for a session, or null when none yet. */
1812
+ getTaskDefaults(sessionId) {
1813
+ return this.persistence.state.taskOptionDefaults?.[sessionId] ?? null;
1480
1814
  }
1481
1815
  /** Approve every non-destructive proposed action on a turn, in order. */
1482
1816
  async approveAll(messageId) {
@@ -1499,13 +1833,41 @@ class ConductorService {
1499
1833
  findAction(messageId, actionId) {
1500
1834
  return this.messages.find((m) => m.id === messageId)?.actions?.find((a) => a.id === actionId);
1501
1835
  }
1836
+ /**
1837
+ * The session whose repo a task-creating action targets — where the card's
1838
+ * options apply and under which the per-repo defaults are stored.
1839
+ */
1840
+ taskTargetSessionId(action) {
1841
+ const a = action.args;
1842
+ if (action.kind === "create_worktree_task") return String(a.parentSessionId ?? "") || null;
1843
+ if (action.kind === "author_feature") return String(a.sessionId ?? "") || null;
1844
+ return null;
1845
+ }
1846
+ /** Remember the card's choices as the defaults for that repo's next proposal. */
1847
+ saveTaskDefaults(action, options) {
1848
+ const sessionId = this.taskTargetSessionId(action);
1849
+ if (!sessionId) return;
1850
+ const map = this.persistence.state.taskOptionDefaults ??= {};
1851
+ map[sessionId] = options;
1852
+ this.persistence.scheduleSave();
1853
+ }
1854
+ /**
1855
+ * The effective task options for an action: the card's explicit choices when
1856
+ * given, else the repo's persisted defaults (so Approve-all and re-approvals
1857
+ * honour the last configuration), else none (the planner's args as-is).
1858
+ */
1859
+ taskOptionsFor(action, explicit) {
1860
+ if (explicit) return explicit;
1861
+ const sessionId = this.taskTargetSessionId(action);
1862
+ return sessionId && this.persistence.state.taskOptionDefaults?.[sessionId] || void 0;
1863
+ }
1502
1864
  /** Run one action by dispatching to the existing services; records the outcome. */
1503
- async runAction(action) {
1865
+ async runAction(action, options) {
1504
1866
  action.status = "running";
1505
1867
  action.result = void 0;
1506
1868
  this.persistAndBroadcast();
1507
1869
  try {
1508
- action.result = await this.dispatch(action);
1870
+ action.result = await this.dispatch(action, options);
1509
1871
  action.status = "done";
1510
1872
  } catch (err) {
1511
1873
  action.status = "error";
@@ -1514,7 +1876,7 @@ class ConductorService {
1514
1876
  this.persistAndBroadcast();
1515
1877
  }
1516
1878
  /** Map one approved action to a concrete service call; returns a result line. */
1517
- async dispatch(action) {
1879
+ async dispatch(action, options) {
1518
1880
  const a = action.args;
1519
1881
  switch (action.kind) {
1520
1882
  case "create_session": {
@@ -1530,9 +1892,19 @@ class ConductorService {
1530
1892
  case "author_feature": {
1531
1893
  const session = this.requireSession(String(a.sessionId ?? ""));
1532
1894
  const feature = this.makeFeature(session.config.id, a);
1895
+ const opts = a.implement ? this.taskOptionsFor(action, options) : void 0;
1896
+ if (opts) {
1897
+ if (opts.createPr) feature.completion = "pr";
1898
+ if (opts.createPr || opts.autoMerge) feature.autoComplete = true;
1899
+ }
1533
1900
  this.features.save(feature);
1534
1901
  if (a.implement) {
1535
- const task = await this.features.implement(feature.id);
1902
+ const model = opts && opts.model !== "inherit" ? opts.model : void 0;
1903
+ const task = await this.features.implement(
1904
+ feature.id,
1905
+ opts?.baseBranch.trim() || void 0,
1906
+ model
1907
+ );
1536
1908
  return `Drafted “${feature.title}” and spun a task to implement it (${task.config.name}).`;
1537
1909
  }
1538
1910
  return `Drafted feature “${feature.title}” with ${feature.specs.length} spec(s).`;
@@ -1547,13 +1919,24 @@ class ConductorService {
1547
1919
  const parent = this.requireSession(String(a.parentSessionId ?? ""));
1548
1920
  const branch = String(a.branch ?? "").trim();
1549
1921
  if (!branch) throw new Error("A branch name is required.");
1922
+ const opts = this.taskOptionsFor(action, options);
1923
+ const baseBranch = (opts?.baseBranch.trim() || String(a.baseBranch ?? "")).trim();
1924
+ const model = opts && opts.model !== "inherit" ? opts.model : void 0;
1550
1925
  const task = await this.sessions.createWorktreeSession(parent.config.id, {
1551
1926
  name: a.name ? String(a.name) : branch,
1552
1927
  branch,
1553
- baseBranch: String(a.baseBranch ?? "").trim(),
1554
- initialPrompt: a.initialPrompt ? String(a.initialPrompt) : void 0
1928
+ baseBranch,
1929
+ initialPrompt: a.initialPrompt ? String(a.initialPrompt) : void 0,
1930
+ ...opts?.createPr ? { completion: "pr" } : {},
1931
+ ...opts && (opts.createPr || opts.autoMerge) ? { autoComplete: true } : {},
1932
+ ...model ? { model } : {}
1555
1933
  });
1556
- return `Spun task “${task.config.name}” on branch ${branch}.`;
1934
+ const extras = [
1935
+ model ? `model ${model}` : "",
1936
+ opts?.createPr ? "PR on completion" : "",
1937
+ opts?.autoMerge ? "auto-merge when done" : ""
1938
+ ].filter(Boolean);
1939
+ return `Spun task “${task.config.name}” on branch ${branch}` + (extras.length ? ` (${extras.join(", ")})` : "") + ".";
1557
1940
  }
1558
1941
  case "queue_prompt": {
1559
1942
  const session = this.requireSession(String(a.sessionId ?? ""));
@@ -1685,11 +2068,12 @@ class ConductorService {
1685
2068
  return focusId ? { focusedSessionId: focusId, sessions } : { sessions };
1686
2069
  }
1687
2070
  /** Compose the full planner prompt: role, action catalog, snapshot, history, ask. */
1688
- buildPrompt(snapshot, latest, focusId) {
2071
+ buildPrompt(snapshot, latest, focusId, images = []) {
1689
2072
  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) => {
2073
+ 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
2074
  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}`;
2075
+ const imgs = m.images?.length ? ` [attached: ${m.images.map((i) => i.path).join(", ")}]` : "";
2076
+ return `${m.role.toUpperCase()}: ${m.text}${imgs}${acts}`;
1693
2077
  }).join("\n");
1694
2078
  return [
1695
2079
  "You are the Conductor for Maestro, a desktop app that runs many Claude Code CLI",
@@ -1712,7 +2096,8 @@ class ConductorService {
1712
2096
  ${history}
1713
2097
  ` : "",
1714
2098
  `THE USER NOW SAYS:
1715
- ${latest}`,
2099
+ ${latest || "(no text — see the attached images)"}`,
2100
+ 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
2101
  "",
1717
2102
  "Respond with EXACTLY ONE JSON object and nothing else — no markdown fences, no prose",
1718
2103
  "around it — shaped like:",
@@ -1939,6 +2324,47 @@ function deleteArtifactFile(kind, name) {
1939
2324
  } catch {
1940
2325
  }
1941
2326
  }
2327
+ function listInstalled() {
2328
+ const out = [];
2329
+ if (fs.existsSync(SKILLS_DIR)) {
2330
+ let dirs = [];
2331
+ try {
2332
+ dirs = fs.readdirSync(SKILLS_DIR);
2333
+ } catch {
2334
+ dirs = [];
2335
+ }
2336
+ for (const dir of dirs) {
2337
+ const file = path.join(SKILLS_DIR, dir, "SKILL.md");
2338
+ if (!fs.existsSync(file)) continue;
2339
+ try {
2340
+ const fm = parseFrontmatter(fs.readFileSync(file, "utf8"));
2341
+ out.push({ kind: "skill", name: fm.name?.trim() || dir, description: fm.description ?? "", filePath: file });
2342
+ } catch {
2343
+ out.push({ kind: "skill", name: dir, description: "", filePath: file });
2344
+ }
2345
+ }
2346
+ }
2347
+ if (fs.existsSync(AGENTS_DIR)) {
2348
+ let entries = [];
2349
+ try {
2350
+ entries = fs.readdirSync(AGENTS_DIR);
2351
+ } catch {
2352
+ entries = [];
2353
+ }
2354
+ for (const entry of entries) {
2355
+ if (!entry.endsWith(".md")) continue;
2356
+ const file = path.join(AGENTS_DIR, entry);
2357
+ const base = entry.replace(/\.md$/, "");
2358
+ try {
2359
+ const fm = parseFrontmatter(fs.readFileSync(file, "utf8"));
2360
+ out.push({ kind: "agent", name: fm.name?.trim() || base, description: fm.description ?? "", filePath: file });
2361
+ } catch {
2362
+ out.push({ kind: "agent", name: base, description: "", filePath: file });
2363
+ }
2364
+ }
2365
+ }
2366
+ return out.sort((a, b) => a.name.localeCompare(b.name));
2367
+ }
1942
2368
  function scanAgents() {
1943
2369
  if (!fs.existsSync(AGENTS_DIR)) return [];
1944
2370
  let entries;
@@ -1961,11 +2387,21 @@ function scanAgents() {
1961
2387
  }
1962
2388
  return out.sort((a, b) => a.name.localeCompare(b.name));
1963
2389
  }
1964
- const EMPTY = { artifacts: [], topics: [], lessons: [] };
2390
+ const EMPTY = { artifacts: [], topics: [], lessons: [], suggestions: [] };
2391
+ const EMPTY_GROWTH = {
2392
+ lastScannedAt: {},
2393
+ lastAutoProposeAt: 0,
2394
+ lastDetectAt: 0,
2395
+ turnsSinceDetect: 0,
2396
+ judgeDay: "",
2397
+ judgeCallsToday: 0
2398
+ };
1965
2399
  class FactoryStore {
1966
2400
  file = path.join(electron.app.getPath("userData"), "factory.json");
1967
2401
  timer = null;
1968
2402
  state = { ...EMPTY };
2403
+ runs = [];
2404
+ growth = { ...EMPTY_GROWTH };
1969
2405
  /** Load the saved registry (best-effort; an empty registry on any error). */
1970
2406
  load() {
1971
2407
  try {
@@ -1973,21 +2409,48 @@ class FactoryStore {
1973
2409
  this.state = {
1974
2410
  artifacts: Array.isArray(raw?.artifacts) ? raw.artifacts : [],
1975
2411
  topics: Array.isArray(raw?.topics) ? raw.topics : [],
1976
- lessons: Array.isArray(raw?.lessons) ? raw.lessons : []
2412
+ lessons: Array.isArray(raw?.lessons) ? raw.lessons : [],
2413
+ // Back-compat: older factory.json files have no `suggestions` key.
2414
+ suggestions: Array.isArray(raw?.suggestions) ? raw.suggestions : []
1977
2415
  };
2416
+ this.runs = Array.isArray(raw?.runs) ? raw.runs : [];
2417
+ this.growth = raw?.growth && typeof raw.growth === "object" ? { ...EMPTY_GROWTH, ...raw.growth } : { ...EMPTY_GROWTH };
2418
+ if (!this.growth.lastScannedAt || typeof this.growth.lastScannedAt !== "object" || Array.isArray(this.growth.lastScannedAt)) {
2419
+ this.growth.lastScannedAt = {};
2420
+ }
1978
2421
  } catch {
1979
2422
  this.state = { ...EMPTY };
2423
+ this.runs = [];
2424
+ this.growth = { ...EMPTY_GROWTH };
1980
2425
  }
1981
2426
  return this.state;
1982
2427
  }
1983
2428
  get() {
1984
2429
  return this.state;
1985
2430
  }
2431
+ /** The persisted run audit trail (call after load()). */
2432
+ loadRuns() {
2433
+ return this.runs;
2434
+ }
2435
+ /** Self-growth bookkeeping (call after load()). */
2436
+ loadGrowth() {
2437
+ return this.growth;
2438
+ }
1986
2439
  /** Replace the registry and schedule a save. */
1987
2440
  set(state) {
1988
2441
  this.state = state;
1989
2442
  this.scheduleSave();
1990
2443
  }
2444
+ /** Replace the run audit trail and schedule a save. */
2445
+ setRuns(runs) {
2446
+ this.runs = runs;
2447
+ this.scheduleSave();
2448
+ }
2449
+ /** Replace the self-growth bookkeeping and schedule a save. */
2450
+ setGrowth(growth) {
2451
+ this.growth = growth;
2452
+ this.scheduleSave();
2453
+ }
1991
2454
  scheduleSave() {
1992
2455
  if (this.timer) clearTimeout(this.timer);
1993
2456
  this.timer = setTimeout(() => this.saveNow(), 500);
@@ -2000,7 +2463,11 @@ class FactoryStore {
2000
2463
  try {
2001
2464
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
2002
2465
  const tmp = this.file + ".tmp";
2003
- fs.writeFileSync(tmp, JSON.stringify(this.state, null, 2), "utf8");
2466
+ fs.writeFileSync(
2467
+ tmp,
2468
+ JSON.stringify({ ...this.state, runs: this.runs, growth: this.growth }, null, 2),
2469
+ "utf8"
2470
+ );
2004
2471
  fs.renameSync(tmp, this.file);
2005
2472
  } catch (err) {
2006
2473
  console.error("Failed to persist factory registry:", err);
@@ -2014,6 +2481,22 @@ const MAX_CANDIDATES = 8;
2014
2481
  const MAX_RUNS = 25;
2015
2482
  const MAX_TOPICS = 60;
2016
2483
  const MAX_LESSONS = 40;
2484
+ const TICK_MS$1 = 6e4;
2485
+ const AUTO_PROPOSE_INTERVAL_MS = 6 * 60 * 6e4;
2486
+ const AUTO_PROPOSE_BOOT_GRACE_MS = 10 * 6e4;
2487
+ const DETECT_DEBOUNCE_MS = 4e3;
2488
+ const DETECT_TIMEOUT_MS = 45e3;
2489
+ const MIN_TURNS_SINCE_DETECT = 3;
2490
+ const DETECT_MIN_INTERVAL_MS = 20 * 6e4;
2491
+ const DETECT_CONTEXT_MAX = 6e3;
2492
+ const MIN_CONFIDENCE = 0.6;
2493
+ const MAX_SUGGESTIONS_PER_DETECT = 2;
2494
+ const JUDGE_DAILY_CAP = 12;
2495
+ const MAX_SUGGESTIONS = 60;
2496
+ function localDay$1() {
2497
+ const d = /* @__PURE__ */ new Date();
2498
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
2499
+ }
2017
2500
  const KNOWN_LABELS = {
2018
2501
  claude_ai_Atlassian: "Atlassian (Confluence / Jira)",
2019
2502
  claude_ai_Figma: "Figma",
@@ -2024,14 +2507,41 @@ class FactoryService {
2024
2507
  constructor(getWin2) {
2025
2508
  this.getWin = getWin2;
2026
2509
  this.state = this.store.load();
2510
+ this.runs = restoreRuns(this.store.loadRuns());
2511
+ for (const s of this.state.suggestions) {
2512
+ if (s.status === "creating") {
2513
+ s.status = "open";
2514
+ s.result = void 0;
2515
+ }
2516
+ }
2027
2517
  }
2028
2518
  store = new FactoryStore();
2029
2519
  state;
2030
2520
  runs = [];
2031
2521
  sources = null;
2032
- /** The in-flight agent child, so dispose()/a new run can cancel it. */
2522
+ /** The in-flight cancellable agent child (scan/author/judge), so dispose()/cancel() can kill it. */
2033
2523
  inFlight = null;
2524
+ /** Source-discovery child — a SEPARATE slot from `inFlight` so discovery (which
2525
+ * can run while `busy` is false) never nulls a live scan/author/judge child. */
2526
+ discoverChild = null;
2527
+ /** In-flight discovery promise; concurrent callers join it instead of spawning
2528
+ * a second discovery agent (the single-headless-child invariant). */
2529
+ discovering = null;
2034
2530
  busy = false;
2531
+ /** Resolves when the current heavy op (scan/author/judge) releases the lock —
2532
+ * the public listSources() awaits it before starting a discovery agent so the
2533
+ * two never run concurrently. Null while idle. */
2534
+ busyPromise = null;
2535
+ busyResolve = null;
2536
+ /** Set by cancel(); the in-flight scan/author reports 'cancelled' instead of 'error'. */
2537
+ cancelRequested = false;
2538
+ /** Self-growth background timer; null until start(). */
2539
+ timer = null;
2540
+ /** Debounce timer for the conversation detector. */
2541
+ detectTimer = null;
2542
+ /** Claimed synchronously while an auto-propose pass is dispatched (incl. its
2543
+ * pre-scan discovery await), so the 60s timer can't double-dispatch it. */
2544
+ autoProposing = false;
2035
2545
  getState() {
2036
2546
  return this.state;
2037
2547
  }
@@ -2039,37 +2549,513 @@ class FactoryService {
2039
2549
  return this.runs;
2040
2550
  }
2041
2551
  dispose() {
2552
+ if (this.timer) clearInterval(this.timer);
2553
+ this.timer = null;
2554
+ if (this.detectTimer) clearTimeout(this.detectTimer);
2555
+ this.detectTimer = null;
2042
2556
  try {
2043
2557
  this.inFlight?.kill();
2044
2558
  } catch {
2045
2559
  }
2560
+ try {
2561
+ this.discoverChild?.kill();
2562
+ } catch {
2563
+ }
2046
2564
  this.inFlight = null;
2565
+ this.discoverChild = null;
2047
2566
  this.store.saveNow();
2048
2567
  }
2568
+ // ---------- self-growth: background timer ----------
2569
+ /** Begin the self-growth loop: an infrequent token-spending auto-propose pass. */
2570
+ start() {
2571
+ if (this.timer) return;
2572
+ const g = this.store.loadGrowth();
2573
+ const earliest = Date.now() - AUTO_PROPOSE_INTERVAL_MS + AUTO_PROPOSE_BOOT_GRACE_MS;
2574
+ if (g.lastAutoProposeAt < earliest) {
2575
+ this.store.setGrowth({ ...g, lastAutoProposeAt: earliest });
2576
+ }
2577
+ this.timer = setInterval(() => this.tick(), TICK_MS$1);
2578
+ }
2579
+ tick() {
2580
+ if (this.busy || this.autoProposing) return;
2581
+ const g = this.store.loadGrowth();
2582
+ if (Date.now() - g.lastAutoProposeAt >= AUTO_PROPOSE_INTERVAL_MS) {
2583
+ void this.autoProposePass().catch(() => {
2584
+ });
2585
+ }
2586
+ }
2587
+ /**
2588
+ * Infrequent token-spending pass: scan the connected MCP source that's gone
2589
+ * longest without a scan (round-robin), then convert that scan's proposed
2590
+ * candidates into suggestions (never auto-installed). A dedicated
2591
+ * `autoProposing` guard (claimed synchronously) prevents a second tick from
2592
+ * dispatching while this one is parked in discovery; the budget/rotation slot
2593
+ * is only consumed when a scan actually runs, and we harvest exactly the run
2594
+ * this pass produced (never a stale older one).
2595
+ */
2596
+ async autoProposePass() {
2597
+ if (this.busy || this.autoProposing) return;
2598
+ this.autoProposing = true;
2599
+ try {
2600
+ const sources = await this.listSources().catch(() => []);
2601
+ if (sources.length === 0) return;
2602
+ const g = this.store.loadGrowth();
2603
+ const next = [...sources].sort(
2604
+ (a, b) => (g.lastScannedAt[a.server] ?? 0) - (g.lastScannedAt[b.server] ?? 0)
2605
+ )[0];
2606
+ const runId = await this.scan(
2607
+ next.server,
2608
+ "Automated background scan: surface only a few high-value, clearly groundable candidates."
2609
+ );
2610
+ if (!runId) return;
2611
+ const g2 = this.store.loadGrowth();
2612
+ this.store.setGrowth({
2613
+ ...g2,
2614
+ lastScannedAt: { ...g2.lastScannedAt, [next.server]: Date.now() },
2615
+ lastAutoProposeAt: Date.now()
2616
+ });
2617
+ const run = this.runs.find((r) => r.id === runId);
2618
+ if (run && run.status === "done") this.absorbScanSuggestions(run);
2619
+ } finally {
2620
+ this.autoProposing = false;
2621
+ }
2622
+ }
2623
+ absorbScanSuggestions(run) {
2624
+ let newest = null;
2625
+ let added = 0;
2626
+ for (const c of run.candidates) {
2627
+ if (c.status !== "proposed") continue;
2628
+ if (this.suggestionDuplicate(c.kind, c.name, c.description)) continue;
2629
+ newest = this.enqueueSuggestion({
2630
+ suggestedKind: c.kind,
2631
+ name: c.name,
2632
+ title: c.description,
2633
+ description: c.description,
2634
+ rationale: c.rationale,
2635
+ origin: "scan",
2636
+ sourceRef: run.source,
2637
+ sourceLabel: run.sourceLabel,
2638
+ source: run.source,
2639
+ context: run.summary,
2640
+ topics: c.topics,
2641
+ keywords: c.keywords,
2642
+ existing: c.existing,
2643
+ confidence: 0.8
2644
+ });
2645
+ added++;
2646
+ }
2647
+ if (added > 0) {
2648
+ this.persist();
2649
+ if (newest) this.getWin()?.webContents.send("factory:suggestion", newest);
2650
+ }
2651
+ }
2652
+ // ---------- self-growth: conversation detector ----------
2653
+ /**
2654
+ * Called (fire-and-forget) after each completed Conductor turn. Debounced and
2655
+ * heavily rate-limited; schedules a short headless judge that may queue
2656
+ * skill/agent suggestions. Never blocks or throws into the caller.
2657
+ */
2658
+ considerConversation(messages) {
2659
+ const g = this.store.loadGrowth();
2660
+ this.store.setGrowth({ ...g, turnsSinceDetect: (g.turnsSinceDetect ?? 0) + 1 });
2661
+ if (this.detectTimer) clearTimeout(this.detectTimer);
2662
+ this.detectTimer = setTimeout(() => {
2663
+ this.detectTimer = null;
2664
+ void this.maybeRunJudge(messages).catch(() => {
2665
+ });
2666
+ }, DETECT_DEBOUNCE_MS);
2667
+ }
2668
+ async maybeRunJudge(messages) {
2669
+ if (this.busy || this.discovering) return;
2670
+ const g = this.store.loadGrowth();
2671
+ const now = Date.now();
2672
+ if (g.turnsSinceDetect < MIN_TURNS_SINCE_DETECT && now - g.lastDetectAt < DETECT_MIN_INTERVAL_MS) {
2673
+ return;
2674
+ }
2675
+ const turnsAtStart = g.turnsSinceDetect;
2676
+ const day = localDay$1();
2677
+ const callsToday = g.judgeDay === day ? g.judgeCallsToday : 0;
2678
+ if (callsToday >= JUDGE_DAILY_CAP) return;
2679
+ const recent = messages.filter((m) => !m.pending && m.text?.trim()).slice(-6);
2680
+ if (recent.length === 0) return;
2681
+ this.setBusy(true);
2682
+ this.cancelRequested = false;
2683
+ try {
2684
+ const convo = recent.map((m) => `${m.role === "user" ? "USER" : "ASSISTANT"}: ${m.text.trim()}`).join("\n\n");
2685
+ const out = await runHeadlessClaude({
2686
+ cwd: process.cwd(),
2687
+ prompt: this.judgePrompt(convo),
2688
+ allowedTools: ["Read"],
2689
+ timeoutMs: DETECT_TIMEOUT_MS,
2690
+ onSpawn: (child) => this.inFlight = child
2691
+ });
2692
+ this.absorbChatSuggestions(
2693
+ this.parseJudge(out),
2694
+ recent[recent.length - 1].id,
2695
+ convo.slice(0, DETECT_CONTEXT_MAX)
2696
+ );
2697
+ } catch {
2698
+ } finally {
2699
+ this.inFlight = null;
2700
+ this.setBusy(false);
2701
+ const g2 = this.store.loadGrowth();
2702
+ const day2 = localDay$1();
2703
+ this.store.setGrowth({
2704
+ ...g2,
2705
+ lastDetectAt: Date.now(),
2706
+ turnsSinceDetect: Math.max(0, (g2.turnsSinceDetect ?? 0) - turnsAtStart),
2707
+ judgeDay: day2,
2708
+ judgeCallsToday: (g2.judgeDay === day2 ? g2.judgeCallsToday : 0) + 1
2709
+ });
2710
+ }
2711
+ }
2712
+ judgePrompt(convo) {
2713
+ const existing = this.existingNamesSnapshot();
2714
+ return [
2715
+ "You are the talent scout for an Agent & Skill Factory inside Maestro. You read a recent",
2716
+ "slice of the user's Conductor chat and decide whether it reveals a REUSABLE workflow or",
2717
+ "body of knowledge worth capturing as a Claude Code SKILL (a repeatable procedure the user",
2718
+ "invokes) or SUB-AGENT (a specialist for one bounded domain).",
2719
+ "",
2720
+ "Be conservative: most chats are one-offs and should yield NOTHING. Only suggest when the",
2721
+ "same kind of task would plausibly recur. Never suggest something that overlaps an artifact",
2722
+ "that already exists below — neither a near-duplicate name nor the same purpose.",
2723
+ "",
2724
+ `Artifacts that already exist (do NOT duplicate):
2725
+ ${JSON.stringify(existing, null, 2)}`,
2726
+ "",
2727
+ "Recent Conductor conversation (oldest first):",
2728
+ convo,
2729
+ "",
2730
+ "Respond with ONLY one JSON object — no markdown fences, no prose:",
2731
+ '{"suggest": <true|false>,',
2732
+ ' "items": [{"kind":"skill|agent",',
2733
+ ' "name":"<kebab-case-slug>",',
2734
+ ' "title":"<short human title>",',
2735
+ ' "description":"<one line: when to use it>",',
2736
+ ' "rationale":"<why this conversation shows it would recur>",',
2737
+ ' "confidence": <0..1>}]}',
2738
+ 'Return {"suggest": false, "items": []} when nothing is worth capturing.'
2739
+ ].join("\n");
2740
+ }
2741
+ /** Existing skills/agents (registered + on-disk), for judge prompts and dedupe. */
2742
+ existingNamesSnapshot() {
2743
+ return [
2744
+ ...this.state.artifacts.map((a) => ({ kind: a.kind, name: a.name, description: a.description })),
2745
+ ...scanSkills().map((s) => ({ kind: "skill", name: s.name, description: s.description ?? "" })),
2746
+ ...scanAgents().map((a) => ({ kind: "agent", name: a.name, description: a.description ?? "" }))
2747
+ ];
2748
+ }
2749
+ parseJudge(out) {
2750
+ const parsed = extractJson(out);
2751
+ if (!parsed || parsed.suggest === false) return [];
2752
+ const raw = Array.isArray(parsed.items) ? parsed.items : [];
2753
+ const items = [];
2754
+ for (const r of raw) {
2755
+ if (!r || typeof r !== "object") continue;
2756
+ const o = r;
2757
+ const kind = o.kind === "agent" ? "agent" : "skill";
2758
+ const name = slugify(String(o.name ?? ""));
2759
+ const description = String(o.description ?? "").trim();
2760
+ if (!name || !description) continue;
2761
+ let confidence = Number(o.confidence);
2762
+ if (!Number.isFinite(confidence)) confidence = 0;
2763
+ items.push({
2764
+ kind,
2765
+ name,
2766
+ title: String(o.title ?? description).trim() || description,
2767
+ description,
2768
+ rationale: String(o.rationale ?? "").trim(),
2769
+ confidence: Math.max(0, Math.min(1, confidence))
2770
+ });
2771
+ }
2772
+ return items;
2773
+ }
2774
+ absorbChatSuggestions(items, sourceMsgId, context) {
2775
+ let newest = null;
2776
+ let added = 0;
2777
+ for (const it of items) {
2778
+ if (added >= MAX_SUGGESTIONS_PER_DETECT) break;
2779
+ if (it.confidence < MIN_CONFIDENCE) continue;
2780
+ if (this.suggestionDuplicate(it.kind, it.name, it.title)) continue;
2781
+ newest = this.enqueueSuggestion({
2782
+ suggestedKind: it.kind,
2783
+ name: it.name,
2784
+ title: it.title,
2785
+ description: it.description,
2786
+ rationale: it.rationale,
2787
+ origin: "chat",
2788
+ sourceRef: sourceMsgId,
2789
+ sourceLabel: "Maestro chat",
2790
+ source: null,
2791
+ context,
2792
+ topics: [],
2793
+ keywords: [],
2794
+ existing: null,
2795
+ confidence: it.confidence
2796
+ });
2797
+ added++;
2798
+ }
2799
+ if (added > 0) {
2800
+ this.persist();
2801
+ if (newest) this.getWin()?.webContents.send("factory:suggestion", newest);
2802
+ }
2803
+ }
2804
+ // ---------- self-growth: suggestion queue ----------
2805
+ enqueueSuggestion(input) {
2806
+ const now = Date.now();
2807
+ const s = { id: crypto.randomUUID(), status: "open", createdAt: now, updatedAt: now, ...input };
2808
+ this.state.suggestions.push(s);
2809
+ this.capSuggestions();
2810
+ return s;
2811
+ }
2812
+ /**
2813
+ * Keep the queue bounded by pruning ONLY the oldest terminal (created/dismissed)
2814
+ * entries. Actionable suggestions (open/creating/error) are never dropped — if
2815
+ * those alone exceed the cap we let the queue run over rather than silently
2816
+ * losing work the user still has to act on.
2817
+ */
2818
+ capSuggestions() {
2819
+ if (this.state.suggestions.length <= MAX_SUGGESTIONS) return;
2820
+ const terminalOldestFirst = this.state.suggestions.filter((s) => s.status === "created" || s.status === "dismissed").sort((a, b) => a.updatedAt - b.updatedAt);
2821
+ const drop = /* @__PURE__ */ new Set();
2822
+ for (const s of terminalOldestFirst) {
2823
+ if (this.state.suggestions.length - drop.size <= MAX_SUGGESTIONS) break;
2824
+ drop.add(s.id);
2825
+ }
2826
+ if (drop.size > 0) {
2827
+ this.state.suggestions = this.state.suggestions.filter((x) => !drop.has(x.id));
2828
+ }
2829
+ }
2830
+ /**
2831
+ * Deterministic, token-free dedupe: is this idea already installed/registered,
2832
+ * or already in the open queue, or semantically the same as one of those?
2833
+ */
2834
+ suggestionDuplicate(kind, slug, title) {
2835
+ const key = `${kind}:${slug}`;
2836
+ const installed = /* @__PURE__ */ new Set([
2837
+ ...this.state.artifacts.map((a) => `${a.kind}:${a.name}`),
2838
+ ...listInstalled().map((i) => `${i.kind}:${i.name}`)
2839
+ ]);
2840
+ if (installed.has(key)) return "artifact";
2841
+ const words = (t2) => new Set(t2.toLowerCase().replace(/[^a-z0-9 ]+/g, " ").split(/\s+/).filter(Boolean));
2842
+ const jaccard = (a, b) => {
2843
+ if (a.size === 0 || b.size === 0) return 0;
2844
+ let inter = 0;
2845
+ for (const w of a) if (b.has(w)) inter++;
2846
+ return inter / (a.size + b.size - inter);
2847
+ };
2848
+ const t = words(title);
2849
+ for (const s of this.state.suggestions) {
2850
+ if (s.status !== "open" && s.status !== "creating") continue;
2851
+ if (s.suggestedKind === kind && s.name === slug) return "suggestion";
2852
+ if (jaccard(t, words(s.title)) >= 0.6) return "suggestion";
2853
+ }
2854
+ for (const a of this.state.artifacts) {
2855
+ if (jaccard(t, words(`${a.name} ${a.description}`)) >= 0.6) return "artifact";
2856
+ }
2857
+ return null;
2858
+ }
2859
+ /** Author + write + register the artifact for a suggestion (the only way one is built). */
2860
+ async createFromSuggestion(id, kind) {
2861
+ const s = this.state.suggestions.find((x) => x.id === id);
2862
+ if (!s || s.status !== "open" && s.status !== "error") return;
2863
+ if (this.busy) return;
2864
+ const useKind = kind ?? s.suggestedKind;
2865
+ this.setBusy(true);
2866
+ this.cancelRequested = false;
2867
+ s.status = "creating";
2868
+ s.suggestedKind = useKind;
2869
+ s.result = void 0;
2870
+ s.updatedAt = Date.now();
2871
+ this.persist();
2872
+ try {
2873
+ await this.discovering?.catch(() => {
2874
+ });
2875
+ const slug = slugify(s.name);
2876
+ let authored;
2877
+ if (s.origin === "scan") {
2878
+ const sources = await this.discoverCache().catch(() => []);
2879
+ const source = sources.find((x) => x.server === s.source) ?? { server: s.source ?? "mcp", label: s.sourceLabel, toolPrefix: `mcp__${s.source ?? ""}`, readTools: [] };
2880
+ const candidate = {
2881
+ id: s.id,
2882
+ kind: useKind,
2883
+ name: slug,
2884
+ description: s.description,
2885
+ topics: s.topics,
2886
+ keywords: s.keywords,
2887
+ rationale: s.rationale,
2888
+ existing: s.existing,
2889
+ status: "authoring"
2890
+ };
2891
+ const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
2892
+ const out = await runHeadlessClaude({
2893
+ cwd: process.cwd(),
2894
+ prompt: this.authorPrompt(source, candidate, slug),
2895
+ allowedTools,
2896
+ timeoutMs: AUTHOR_TIMEOUT_MS,
2897
+ onSpawn: (child) => this.inFlight = child
2898
+ });
2899
+ authored = this.parseAuthor(out);
2900
+ } else {
2901
+ const out = await runHeadlessClaude({
2902
+ cwd: process.cwd(),
2903
+ prompt: this.conversationAuthorPrompt(s, useKind, slug),
2904
+ allowedTools: ["Read", "Grep", "Glob"],
2905
+ timeoutMs: AUTHOR_TIMEOUT_MS,
2906
+ onSpawn: (child) => this.inFlight = child
2907
+ });
2908
+ authored = this.parseAuthor(out);
2909
+ }
2910
+ if (!authored) throw new Error("The author agent did not return usable file content.");
2911
+ const filePath = useKind === "skill" ? writeSkill(slug, authored.content) : writeAgent(slug, authored.content);
2912
+ this.registerArtifact({
2913
+ kind: useKind,
2914
+ name: slug,
2915
+ filePath,
2916
+ description: authored.description || s.description,
2917
+ topics: authored.topics.length ? authored.topics : s.topics,
2918
+ keywords: authored.keywords.length ? authored.keywords : s.keywords,
2919
+ source: s.origin === "scan" ? s.source ?? "scan" : "conversation",
2920
+ related: authored.related
2921
+ });
2922
+ const artifact = this.state.artifacts.find((a) => a.kind === useKind && a.name === slug);
2923
+ s.status = "created";
2924
+ s.artifactId = artifact?.id;
2925
+ s.filePath = filePath;
2926
+ s.result = `Wrote ${useKind} to ${filePath}`;
2927
+ } catch (err) {
2928
+ if (this.cancelRequested) {
2929
+ s.status = "open";
2930
+ s.result = void 0;
2931
+ } else {
2932
+ s.status = "error";
2933
+ s.result = err.message || String(err);
2934
+ }
2935
+ } finally {
2936
+ s.updatedAt = Date.now();
2937
+ this.inFlight = null;
2938
+ this.setBusy(false);
2939
+ this.persist();
2940
+ }
2941
+ }
2942
+ /** Dismiss a suggestion without building it (kept as history; may resurface later). */
2943
+ dismissSuggestion(id) {
2944
+ const s = this.state.suggestions.find((x) => x.id === id);
2945
+ if (!s || s.status !== "open" && s.status !== "error") return;
2946
+ s.status = "dismissed";
2947
+ s.updatedAt = Date.now();
2948
+ this.persist();
2949
+ }
2950
+ conversationAuthorPrompt(s, kind, slug) {
2951
+ const related = this.state.artifacts.map((a) => ({ name: a.name, kind: a.kind, description: a.description }));
2952
+ const lessons = this.state.lessons.map((l) => l.text);
2953
+ const isSkill = kind === "skill";
2954
+ return [
2955
+ `You are authoring a Claude Code ${isSkill ? "SKILL" : "SUB-AGENT"} that captures a reusable`,
2956
+ "workflow the user demonstrated in a Maestro Conductor conversation. You run unattended.",
2957
+ "",
2958
+ `Target artifact:
2959
+ ${JSON.stringify({ kind, name: slug, description: s.description, topics: s.topics, rationale: s.rationale }, null, 2)}`,
2960
+ "",
2961
+ "The conversation that motivated this artifact — capture the GENERAL, reusable procedure it",
2962
+ "shows; strip one-off specifics (particular file names, ids, values) and keep the repeatable",
2963
+ "method:",
2964
+ s.context || "(no excerpt was captured; rely on the target description above)",
2965
+ "",
2966
+ "Write the COMPLETE file content as Markdown with a YAML frontmatter block.",
2967
+ isSkill ? [
2968
+ "For a SKILL the file is SKILL.md. Frontmatter MUST be exactly:",
2969
+ "---",
2970
+ `name: ${slug}`,
2971
+ "description: <one line describing WHEN to use this skill>",
2972
+ "---",
2973
+ "Then the body: a focused, step-by-step procedure the agent can follow."
2974
+ ].join("\n") : [
2975
+ "For a SUB-AGENT the file is an agent definition. Frontmatter MUST be exactly:",
2976
+ "---",
2977
+ `name: ${slug}`,
2978
+ "description: <one line: when to route to this agent — be specific with trigger terms>",
2979
+ "model: claude-sonnet-4-6",
2980
+ "---",
2981
+ "Then the body: the agent's system prompt — its scope, what it knows, and what it does NOT cover."
2982
+ ].join("\n"),
2983
+ "",
2984
+ `Other artifacts that exist (name any genuinely related):
2985
+ ${JSON.stringify(related, null, 2)}`,
2986
+ lessons.length ? `
2987
+ Lessons learned (respect these):
2988
+ - ${lessons.join("\n- ")}` : "",
2989
+ "",
2990
+ "Respond with ONLY one JSON object — no markdown fences around the whole object, no prose:",
2991
+ '{"content":"<the FULL file content, frontmatter + body, as a single string>",',
2992
+ ' "description":"<final one-line description>",',
2993
+ ' "topics":["..."], "keywords":["..."],',
2994
+ ' "related":["<names of related existing artifacts>"]}'
2995
+ ].filter((l) => l !== "").join("\n");
2996
+ }
2997
+ /** Cancel the in-flight scan/author agent, if any (the run reports 'cancelled'). */
2998
+ cancel() {
2999
+ if (!this.inFlight) return;
3000
+ this.cancelRequested = true;
3001
+ try {
3002
+ this.inFlight.kill();
3003
+ } catch {
3004
+ }
3005
+ }
3006
+ /** Drop finished runs from the audit trail (a running one is kept). */
3007
+ clearRuns() {
3008
+ this.runs = this.runs.filter((r) => r.status === "running");
3009
+ this.broadcastRuns();
3010
+ }
2049
3011
  // ---------- source discovery (phase 0) ----------
2050
3012
  /**
2051
3013
  * Enumerate the MCP contexts the factory can mine. The connected claude.ai
2052
3014
  * connectors are not in ~/.claude.json, so a no-tool headless agent reports
2053
3015
  * what it can see; we merge that with the user-scope servers from
2054
3016
  * ~/.claude.json. Cached for the app run; `refresh` forces a re-discovery.
3017
+ *
3018
+ * PUBLIC entry point (IPC / renderer refresh / auto-propose pre-scan): if a
3019
+ * heavy op holds the lock, WAIT for it before starting a discovery agent, so
3020
+ * we never run two headless `claude -p` children at once. Lock-holders
3021
+ * (scan/approve/createFromSuggestion) must call discoverCache() instead — they
3022
+ * already hold the lock and would otherwise wait on themselves (deadlock).
2055
3023
  */
2056
3024
  async listSources(refresh = false) {
2057
3025
  if (this.sources && !refresh) return this.sources;
2058
- const discovered = await this.discoverSources().catch(() => []);
2059
- const byKey = /* @__PURE__ */ new Map();
2060
- for (const s of discovered) byKey.set(s.server, s);
2061
- for (const m of readUserMcpServers()) {
2062
- if (!byKey.has(m.name)) {
2063
- byKey.set(m.name, {
2064
- server: m.name,
2065
- label: KNOWN_LABELS[m.name] ?? m.name,
2066
- toolPrefix: `mcp__${m.name}`,
2067
- readTools: []
2068
- });
3026
+ while (this.busy && this.busyPromise) await this.busyPromise;
3027
+ return this.discoverCache(refresh);
3028
+ }
3029
+ /**
3030
+ * The actual cached/memoized discovery, with NO busy-wait. Safe to call from a
3031
+ * lock-holder because discovery runs before that op spawns its own child, so
3032
+ * only one headless child is ever alive. Concurrent callers join one discovery.
3033
+ */
3034
+ discoverCache(refresh = false) {
3035
+ if (this.sources && !refresh) return Promise.resolve(this.sources);
3036
+ if (this.discovering) return this.discovering;
3037
+ this.discovering = (async () => {
3038
+ try {
3039
+ const discovered = await this.discoverSources().catch(() => []);
3040
+ const byKey = /* @__PURE__ */ new Map();
3041
+ for (const s of discovered) byKey.set(s.server, s);
3042
+ for (const m of readUserMcpServers()) {
3043
+ if (!byKey.has(m.name)) {
3044
+ byKey.set(m.name, {
3045
+ server: m.name,
3046
+ label: KNOWN_LABELS[m.name] ?? m.name,
3047
+ toolPrefix: `mcp__${m.name}`,
3048
+ readTools: []
3049
+ });
3050
+ }
3051
+ }
3052
+ this.sources = [...byKey.values()].sort((a, b) => a.label.localeCompare(b.label));
3053
+ return this.sources;
3054
+ } finally {
3055
+ this.discovering = null;
2069
3056
  }
2070
- }
2071
- this.sources = [...byKey.values()].sort((a, b) => a.label.localeCompare(b.label));
2072
- return this.sources;
3057
+ })();
3058
+ return this.discovering;
2073
3059
  }
2074
3060
  async discoverSources() {
2075
3061
  const prompt = [
@@ -2096,8 +3082,8 @@ class FactoryService {
2096
3082
  prompt,
2097
3083
  allowedTools: ["Read"],
2098
3084
  timeoutMs: DISCOVER_TIMEOUT_MS,
2099
- onSpawn: (child) => this.inFlight = child
2100
- }).finally(() => this.inFlight = null);
3085
+ onSpawn: (child) => this.discoverChild = child
3086
+ }).finally(() => this.discoverChild = null);
2101
3087
  const parsed = extractJson(out);
2102
3088
  const list = Array.isArray(parsed?.servers) ? parsed.servers : [];
2103
3089
  const sources = [];
@@ -2117,29 +3103,37 @@ class FactoryService {
2117
3103
  return sources;
2118
3104
  }
2119
3105
  // ---------- scan (phase 1) ----------
2120
- /** Explore a source and propose skill/agent candidates. */
3106
+ /**
3107
+ * Explore a source and propose skill/agent candidates. Claims the single
3108
+ * `busy` lock SYNCHRONOUSLY (before any await) so two headless agents can
3109
+ * never run at once. Returns the created run's id, or null if it bailed on
3110
+ * the busy guard (no run, no tokens spent) — callers (auto-propose) use this
3111
+ * to harvest exactly this run and to only consume budget when a scan ran.
3112
+ * Throws (releasing the lock) only for an unknown source / discovery failure.
3113
+ */
2121
3114
  async scan(serverKey, guidance) {
2122
- if (this.busy) return;
2123
- const sources = await this.listSources();
2124
- const source = sources.find((s) => s.server === serverKey);
2125
- if (!source) throw new Error(`Unknown source: ${serverKey}`);
2126
- this.busy = true;
2127
- const run = {
2128
- id: crypto.randomUUID(),
2129
- source: source.server,
2130
- sourceLabel: source.label,
2131
- guidance: guidance.trim(),
2132
- startedAt: Date.now(),
2133
- finishedAt: null,
2134
- status: "running",
2135
- phase: "discovering",
2136
- candidates: [],
2137
- summary: ""
2138
- };
2139
- this.pushRun(run);
3115
+ if (this.busy) return null;
3116
+ this.setBusy(true);
3117
+ this.cancelRequested = false;
3118
+ let run = null;
2140
3119
  try {
3120
+ const sources = await this.discoverCache();
3121
+ const source = sources.find((s) => s.server === serverKey);
3122
+ if (!source) throw new Error(`Unknown source: ${serverKey}`);
3123
+ run = {
3124
+ id: crypto.randomUUID(),
3125
+ source: source.server,
3126
+ sourceLabel: source.label,
3127
+ guidance: guidance.trim(),
3128
+ startedAt: Date.now(),
3129
+ finishedAt: null,
3130
+ status: "running",
3131
+ phase: "discovering",
3132
+ candidates: [],
3133
+ summary: ""
3134
+ };
3135
+ this.pushRun(run);
2141
3136
  const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
2142
- run.phase = "discovering";
2143
3137
  this.broadcastRuns();
2144
3138
  const out = await runHeadlessClaude({
2145
3139
  cwd: process.cwd(),
@@ -2155,15 +3149,24 @@ class FactoryService {
2155
3149
  run.status = "done";
2156
3150
  this.absorbTopics(parsed.newTopics, source.server);
2157
3151
  } catch (err) {
2158
- run.status = "error";
2159
- run.phase = "done";
2160
- run.summary = err.message || String(err);
3152
+ if (run) {
3153
+ run.status = this.cancelRequested ? "cancelled" : "error";
3154
+ run.phase = "done";
3155
+ run.summary = this.cancelRequested ? "Cancelled." : err.message || String(err);
3156
+ } else {
3157
+ this.inFlight = null;
3158
+ this.setBusy(false);
3159
+ throw err;
3160
+ }
2161
3161
  } finally {
2162
- run.finishedAt = Date.now();
2163
- this.inFlight = null;
2164
- this.busy = false;
2165
- this.broadcastRuns();
3162
+ if (run) {
3163
+ run.finishedAt = Date.now();
3164
+ this.inFlight = null;
3165
+ this.setBusy(false);
3166
+ this.broadcastRuns();
3167
+ }
2166
3168
  }
3169
+ return run ? run.id : null;
2167
3170
  }
2168
3171
  scanPrompt(source, guidance) {
2169
3172
  const existing = [
@@ -2256,18 +3259,19 @@ Lessons learned (respect these):
2256
3259
  };
2257
3260
  }
2258
3261
  // ---------- author (phase 2) ----------
2259
- /** Approve a candidate: author its file content and write it to ~/.claude. */
3262
+ /** Approve a candidate: author its file content and write it to ~/.claude. An errored candidate can be retried. */
2260
3263
  async approve(runId, candidateId) {
2261
3264
  const run = this.runs.find((r) => r.id === runId);
2262
3265
  const candidate = run?.candidates.find((c) => c.id === candidateId);
2263
- if (!run || !candidate || candidate.status !== "proposed") return;
3266
+ if (!run || !candidate || candidate.status !== "proposed" && candidate.status !== "error") return;
2264
3267
  if (this.busy) return;
2265
- this.busy = true;
3268
+ this.setBusy(true);
3269
+ this.cancelRequested = false;
2266
3270
  candidate.status = "authoring";
2267
3271
  candidate.result = void 0;
2268
3272
  this.broadcastRuns();
2269
3273
  try {
2270
- const source = (await this.listSources()).find((s) => s.server === run.source) ?? { server: run.source, label: run.sourceLabel, toolPrefix: `mcp__${run.source}`, readTools: [] };
3274
+ const source = (await this.discoverCache()).find((s) => s.server === run.source) ?? { server: run.source, label: run.sourceLabel, toolPrefix: `mcp__${run.source}`, readTools: [] };
2271
3275
  const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
2272
3276
  const slug = slugify(candidate.name);
2273
3277
  const out = await runHeadlessClaude({
@@ -2294,21 +3298,27 @@ Lessons learned (respect these):
2294
3298
  candidate.filePath = filePath;
2295
3299
  candidate.result = `Wrote ${candidate.kind} to ${filePath}`;
2296
3300
  } catch (err) {
2297
- candidate.status = "error";
2298
- candidate.result = err.message || String(err);
3301
+ if (this.cancelRequested) {
3302
+ candidate.status = "proposed";
3303
+ candidate.result = void 0;
3304
+ } else {
3305
+ candidate.status = "error";
3306
+ candidate.result = err.message || String(err);
3307
+ }
2299
3308
  } finally {
2300
3309
  this.inFlight = null;
2301
- this.busy = false;
3310
+ this.setBusy(false);
2302
3311
  this.broadcastRuns();
2303
3312
  this.broadcastState();
2304
3313
  }
2305
3314
  }
2306
- /** Approve every still-proposed candidate on a run, in order. */
3315
+ /** Approve every still-proposed candidate on a run, in order (stops on cancel). */
2307
3316
  async approveAll(runId) {
2308
3317
  const run = this.runs.find((r) => r.id === runId);
2309
3318
  if (!run) return;
2310
3319
  for (const c of run.candidates) {
2311
3320
  if (c.status === "proposed") await this.approve(runId, c.id);
3321
+ if (this.cancelRequested) break;
2312
3322
  }
2313
3323
  }
2314
3324
  reject(runId, candidateId) {
@@ -2425,13 +3435,64 @@ Lessons learned (respect these):
2425
3435
  deleteArtifact(id) {
2426
3436
  const artifact = this.state.artifacts.find((a) => a.id === id);
2427
3437
  if (!artifact) return;
2428
- deleteArtifactFile(artifact.kind, artifact.name);
3438
+ if (!artifact.adopted) deleteArtifactFile(artifact.kind, artifact.name);
3439
+ this.unregister(id);
3440
+ }
3441
+ /** Remove an artifact from the registry WITHOUT touching its file. */
3442
+ unregister(id) {
3443
+ const artifact = this.state.artifacts.find((a) => a.id === id);
3444
+ if (!artifact) return;
2429
3445
  this.state.artifacts = this.state.artifacts.filter((a) => a.id !== id);
2430
3446
  for (const a of this.state.artifacts) {
2431
3447
  a.relatedArtifacts = a.relatedArtifacts.filter((n) => n !== artifact.name);
2432
3448
  }
2433
3449
  this.persist();
2434
3450
  }
3451
+ /** Read a registered artifact's file content (null when the file is missing). */
3452
+ readArtifact(id) {
3453
+ const artifact = this.state.artifacts.find((a) => a.id === id);
3454
+ if (!artifact) return null;
3455
+ try {
3456
+ return fs.readFileSync(artifact.filePath, "utf8");
3457
+ } catch {
3458
+ return null;
3459
+ }
3460
+ }
3461
+ /** Reveal a registered artifact's file in the OS file manager. */
3462
+ revealArtifact(id) {
3463
+ const artifact = this.state.artifacts.find((a) => a.id === id);
3464
+ if (artifact && fs.existsSync(artifact.filePath)) electron.shell.showItemInFolder(artifact.filePath);
3465
+ }
3466
+ // ---------- registry↔disk audit (the lightweight validator) ----------
3467
+ /** Reconcile the registry against ~/.claude on disk. */
3468
+ audit() {
3469
+ const missingFileIds = this.state.artifacts.filter((a) => !fs.existsSync(a.filePath)).map((a) => a.id);
3470
+ const registered = new Set(this.state.artifacts.map((a) => `${a.kind}:${a.name}`));
3471
+ const unregistered = listInstalled().filter((i) => !registered.has(`${i.kind}:${i.name}`));
3472
+ return { missingFileIds, unregistered };
3473
+ }
3474
+ /** Adopt a pre-existing on-disk skill/agent into the registry (file is left as-is). */
3475
+ adopt(kind, name) {
3476
+ if (this.state.artifacts.some((a) => a.kind === kind && a.name === name)) return;
3477
+ const installed = listInstalled().find((i) => i.kind === kind && i.name === name);
3478
+ if (!installed) return;
3479
+ const now = Date.now();
3480
+ this.state.artifacts.push({
3481
+ id: crypto.randomUUID(),
3482
+ kind,
3483
+ name,
3484
+ filePath: installed.filePath,
3485
+ description: installed.description,
3486
+ topics: [],
3487
+ keywords: [],
3488
+ source: "adopted",
3489
+ relatedArtifacts: [],
3490
+ adopted: true,
3491
+ createdAt: now,
3492
+ updatedAt: now
3493
+ });
3494
+ this.persist();
3495
+ }
2435
3496
  // ---------- backlog (topics-to-pursue) ----------
2436
3497
  absorbTopics(topics, source) {
2437
3498
  const have = new Set(this.state.topics.map((t) => t.title.toLowerCase()));
@@ -2491,13 +3552,53 @@ Lessons learned (respect these):
2491
3552
  this.store.set(this.state);
2492
3553
  this.broadcastState();
2493
3554
  }
2494
- broadcastState() {
2495
- this.getWin()?.webContents.send("factory:changed", this.state);
3555
+ broadcastState() {
3556
+ this.getWin()?.webContents.send("factory:changed", this.state);
3557
+ }
3558
+ /**
3559
+ * Single source of truth for the headless lock. Setting it broadcasts so the
3560
+ * renderer can reflect background work (judge / author / scan) that doesn't
3561
+ * create a visible FactoryRun — preventing buttons that would silently no-op
3562
+ * against the lock from staying enabled.
3563
+ */
3564
+ setBusy(v) {
3565
+ if (this.busy === v) return;
3566
+ this.busy = v;
3567
+ if (v) {
3568
+ this.busyPromise = new Promise((resolve) => this.busyResolve = resolve);
3569
+ } else {
3570
+ this.busyResolve?.();
3571
+ this.busyResolve = null;
3572
+ this.busyPromise = null;
3573
+ }
3574
+ this.getWin()?.webContents.send("factory:busy", v);
3575
+ }
3576
+ /** Current headless-lock state (for the initial renderer fetch). */
3577
+ isBusy() {
3578
+ return this.busy;
2496
3579
  }
2497
3580
  broadcastRuns() {
3581
+ this.store.setRuns(this.runs);
2498
3582
  this.getWin()?.webContents.send("factory:runs", this.runs);
2499
3583
  }
2500
3584
  }
3585
+ function restoreRuns(runs) {
3586
+ for (const run of runs) {
3587
+ if (run.status === "running") {
3588
+ run.status = "cancelled";
3589
+ run.phase = "done";
3590
+ run.finishedAt = run.finishedAt ?? Date.now();
3591
+ run.summary = run.summary || "Interrupted — the app was closed mid-scan.";
3592
+ }
3593
+ for (const c of run.candidates) {
3594
+ if (c.status === "authoring") {
3595
+ c.status = "proposed";
3596
+ c.result = void 0;
3597
+ }
3598
+ }
3599
+ }
3600
+ return runs;
3601
+ }
2501
3602
  function toStringArray(v) {
2502
3603
  if (!Array.isArray(v)) return [];
2503
3604
  return [...new Set(v.map((x) => String(x).trim()).filter(Boolean))];
@@ -2563,9 +3664,10 @@ class FeatureService {
2563
3664
  * Links the feature to the spawned session and flips it to 'implementing'.
2564
3665
  * Throws (with git's message) if the parent isn't a repo or the worktree fails.
2565
3666
  * `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).
3667
+ * into (used by auto-expand to keep its growth on a dedicated branch);
3668
+ * `model` pins the task claude's model (used by the Conductor's approval card).
2567
3669
  */
2568
- async implement(featureId, baseBranch) {
3670
+ async implement(featureId, baseBranch, model) {
2569
3671
  const feature = this.features.find((f) => f.id === featureId);
2570
3672
  if (!feature) throw new Error("Unknown feature");
2571
3673
  const parent = this.sessions.getConfig(feature.sessionId);
@@ -2580,7 +3682,8 @@ class FeatureService {
2580
3682
  initialPrompt: implementPrompt(feature),
2581
3683
  // Carry the feature's PR/merge preference onto the implementing task.
2582
3684
  completion: feature.completion,
2583
- autoComplete: feature.autoComplete
3685
+ autoComplete: feature.autoComplete,
3686
+ model
2584
3687
  });
2585
3688
  try {
2586
3689
  const specAbs = path.join(session.config.folder, specRelPath(feature));
@@ -3145,6 +4248,7 @@ class UsageService {
3145
4248
  return entries;
3146
4249
  }
3147
4250
  }
4251
+ const CONDUCTOR_ATTACH_SCOPE = "conductor";
3148
4252
  function tokenize(template) {
3149
4253
  const tokens = [];
3150
4254
  const re = /"([^"]*)"|(\S+)/g;
@@ -3152,7 +4256,7 @@ function tokenize(template) {
3152
4256
  while (m = re.exec(template)) tokens.push(m[1] ?? m[2]);
3153
4257
  return tokens;
3154
4258
  }
3155
- function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, getWin2) {
4259
+ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, getWin2) {
3156
4260
  const rootOf = (id) => {
3157
4261
  const config = sessions.getConfig(id);
3158
4262
  if (!config) throw new Error(`Unknown session: ${id}`);
@@ -3209,6 +4313,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3209
4313
  "git:fileDiff",
3210
4314
  (_e, sessionId, path2) => sessions.getGitFileDiff(sessionId, path2)
3211
4315
  );
4316
+ electron.ipcMain.handle("git:branches", (_e, sessionId) => sessions.listBranches(sessionId));
3212
4317
  electron.ipcMain.handle(
3213
4318
  "checkpoint:create",
3214
4319
  (_e, sessionId, label) => sessions.createCheckpoint(sessionId, label)
@@ -3295,11 +4400,11 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3295
4400
  electron.ipcMain.handle("conductor:list", () => conductor.list());
3296
4401
  electron.ipcMain.handle(
3297
4402
  "conductor:send",
3298
- (_e, text, tagSessionId) => conductor.send(text, tagSessionId ?? null)
4403
+ (_e, text, tagSessionId, images) => conductor.send(text, tagSessionId ?? null, images ?? [])
3299
4404
  );
3300
4405
  electron.ipcMain.handle(
3301
4406
  "conductor:approve",
3302
- (_e, messageId, actionId) => conductor.approve(messageId, actionId)
4407
+ (_e, messageId, actionId, options) => conductor.approve(messageId, actionId, options)
3303
4408
  );
3304
4409
  electron.ipcMain.handle(
3305
4410
  "conductor:approveAll",
@@ -3310,9 +4415,27 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3310
4415
  (_e, messageId, actionId) => conductor.reject(messageId, actionId)
3311
4416
  );
3312
4417
  electron.ipcMain.handle("conductor:clear", () => conductor.clear());
4418
+ electron.ipcMain.handle(
4419
+ "conductor:taskDefaults",
4420
+ (_e, sessionId) => conductor.getTaskDefaults(sessionId)
4421
+ );
4422
+ electron.ipcMain.handle("conductor:attachClipboard", () => attachClipboardImage(CONDUCTOR_ATTACH_SCOPE));
4423
+ electron.ipcMain.handle(
4424
+ "conductor:attachFile",
4425
+ (_e, srcPath) => attachImageFile(CONDUCTOR_ATTACH_SCOPE, srcPath)
4426
+ );
4427
+ electron.ipcMain.handle(
4428
+ "conductor:attachData",
4429
+ (_e, name, bytes) => attachImageData(CONDUCTOR_ATTACH_SCOPE, name, bytes)
4430
+ );
4431
+ electron.ipcMain.handle(
4432
+ "conductor:attachDelete",
4433
+ (_e, fileName) => deleteAttachment(CONDUCTOR_ATTACH_SCOPE, fileName)
4434
+ );
3313
4435
  electron.ipcMain.handle("factory:listSources", (_e, refresh) => factory.listSources(refresh));
3314
4436
  electron.ipcMain.handle("factory:state", () => factory.getState());
3315
4437
  electron.ipcMain.handle("factory:runs", () => factory.listRuns());
4438
+ electron.ipcMain.handle("factory:isBusy", () => factory.isBusy());
3316
4439
  electron.ipcMain.handle(
3317
4440
  "factory:scan",
3318
4441
  (_e, serverKey, guidance) => factory.scan(serverKey, guidance)
@@ -3326,11 +4449,30 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3326
4449
  "factory:reject",
3327
4450
  (_e, runId, candidateId) => factory.reject(runId, candidateId)
3328
4451
  );
4452
+ electron.ipcMain.handle("factory:cancel", () => factory.cancel());
4453
+ electron.ipcMain.handle("factory:clearRuns", () => factory.clearRuns());
3329
4454
  electron.ipcMain.handle("factory:deleteArtifact", (_e, id) => factory.deleteArtifact(id));
4455
+ electron.ipcMain.handle("factory:unregisterArtifact", (_e, id) => factory.unregister(id));
4456
+ electron.ipcMain.handle("factory:readArtifact", (_e, id) => factory.readArtifact(id));
4457
+ electron.ipcMain.handle("factory:revealArtifact", (_e, id) => factory.revealArtifact(id));
4458
+ electron.ipcMain.handle("factory:audit", () => factory.audit());
4459
+ electron.ipcMain.handle(
4460
+ "factory:adopt",
4461
+ (_e, kind, name) => factory.adopt(kind, name)
4462
+ );
3330
4463
  electron.ipcMain.handle("factory:promoteTopic", (_e, id) => factory.promoteTopic(id));
3331
4464
  electron.ipcMain.handle("factory:dismissTopic", (_e, id) => factory.dismissTopic(id));
3332
4465
  electron.ipcMain.handle("factory:addLesson", (_e, text) => factory.addLesson(text));
3333
4466
  electron.ipcMain.handle("factory:deleteLesson", (_e, id) => factory.deleteLesson(id));
4467
+ electron.ipcMain.handle(
4468
+ "factory:createFromSuggestion",
4469
+ (_e, id, kind) => factory.createFromSuggestion(id, kind)
4470
+ );
4471
+ electron.ipcMain.handle("factory:dismissSuggestion", (_e, id) => factory.dismissSuggestion(id));
4472
+ electron.ipcMain.handle("agents:get", () => agents.snapshot());
4473
+ electron.ipcMain.handle("agents:refresh", () => agents.refresh());
4474
+ electron.ipcMain.handle("agents:read", (_e, filePath) => agents.readAgentFile(filePath));
4475
+ electron.ipcMain.handle("agents:reveal", (_e, filePath) => agents.revealAgentFile(filePath));
3334
4476
  electron.ipcMain.handle("actions:list", () => sessions.actions);
3335
4477
  electron.ipcMain.handle("actions:save", (_e, actions) => sessions.saveActions(actions));
3336
4478
  electron.ipcMain.handle(
@@ -3423,6 +4565,33 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3423
4565
  "attachments:delete",
3424
4566
  (_e, id, fileName) => deleteAttachment(id, fileName)
3425
4567
  );
4568
+ electron.ipcMain.handle("tokenEff:status", (_e, sessionId) => tokenEff.status(sessionId));
4569
+ electron.ipcMain.handle("tokenEff:saveGlobal", (_e, config) => {
4570
+ tokenEff.saveGlobal(config);
4571
+ getWin2()?.webContents.send("session:changed");
4572
+ });
4573
+ electron.ipcMain.handle(
4574
+ "tokenEff:setRepoOverride",
4575
+ (_e, sessionId, override) => {
4576
+ tokenEff.setRepoOverride(sessionId, override);
4577
+ getWin2()?.webContents.send("session:changed");
4578
+ }
4579
+ );
4580
+ electron.ipcMain.handle(
4581
+ "tokenEff:setSessionOverride",
4582
+ (_e, sessionId, override) => {
4583
+ tokenEff.setSessionOverride(sessionId, override);
4584
+ getWin2()?.webContents.send("session:changed");
4585
+ }
4586
+ );
4587
+ electron.ipcMain.handle(
4588
+ "tokenEff:refreshRepoMap",
4589
+ (_e, sessionId) => tokenEff.refreshRepoMap(sessionId)
4590
+ );
4591
+ electron.ipcMain.handle(
4592
+ "tokenEff:detectTools",
4593
+ (_e, refresh) => tokenEff.detectTools(refresh ?? false)
4594
+ );
3426
4595
  const usage = new UsageService();
3427
4596
  electron.ipcMain.handle("usage:get", () => usage.snapshot());
3428
4597
  const usageLimits = new UsageLimitsService();
@@ -3459,8 +4628,20 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3459
4628
  Object.assign(persistence2.state.settings, patch);
3460
4629
  persistence2.scheduleSave();
3461
4630
  getWin2()?.webContents.send("session:changed");
4631
+ if (patch.agentRegistryPath !== void 0) agents.refresh();
3462
4632
  });
3463
4633
  }
4634
+ const DEFAULT_TOKEN_EFFICIENCY = {
4635
+ enabled: false,
4636
+ outputCompression: true,
4637
+ codeGraph: true,
4638
+ truncationHooks: true,
4639
+ promptCachingHints: true,
4640
+ bashMaxOutputChars: 3e4,
4641
+ mcpMaxOutputTokens: 25e3,
4642
+ largeReadMaxKB: 256,
4643
+ repoMapMaxFiles: 400
4644
+ };
3464
4645
  const DEFAULT_SETTINGS = {
3465
4646
  editorCommand: 'code "${path}"',
3466
4647
  scrollbackLines: 1e4,
@@ -3472,7 +4653,10 @@ const DEFAULT_SETTINGS = {
3472
4653
  backgroundOpacity: 0.3,
3473
4654
  watchdogEnabled: true,
3474
4655
  watchdogStallMinutes: 10,
3475
- watchdogUnansweredMinutes: 5
4656
+ watchdogUnansweredMinutes: 5,
4657
+ tokenEfficiency: DEFAULT_TOKEN_EFFICIENCY,
4658
+ tokenEfficiencyRepoOverrides: {},
4659
+ agentRegistryPath: "C:\\repos\\agent-factory\\registry\\registry.json"
3476
4660
  };
3477
4661
  const DEFAULT_CATEGORIES = [
3478
4662
  {
@@ -3520,7 +4704,8 @@ const DEFAULT_STATE = {
3520
4704
  settings: DEFAULT_SETTINGS,
3521
4705
  categories: DEFAULT_CATEGORIES,
3522
4706
  actions: [],
3523
- features: []
4707
+ features: [],
4708
+ taskOptionDefaults: {}
3524
4709
  };
3525
4710
  function migrateSession(raw) {
3526
4711
  if (Array.isArray(raw.terminals) && raw.terminals.length > 0) {
@@ -3570,10 +4755,20 @@ class Persistence {
3570
4755
  ...DEFAULT_STATE,
3571
4756
  ...raw,
3572
4757
  window: { ...DEFAULT_STATE.window, ...raw.window ?? {} },
3573
- settings: { ...DEFAULT_SETTINGS, ...raw.settings ?? {} },
4758
+ settings: {
4759
+ ...DEFAULT_SETTINGS,
4760
+ ...raw.settings ?? {},
4761
+ // Nested object — merge so new fields gain their defaults on upgrade.
4762
+ tokenEfficiency: {
4763
+ ...DEFAULT_TOKEN_EFFICIENCY,
4764
+ ...raw.settings?.tokenEfficiency ?? {}
4765
+ },
4766
+ tokenEfficiencyRepoOverrides: raw.settings?.tokenEfficiencyRepoOverrides ?? {}
4767
+ },
3574
4768
  categories: Array.isArray(raw.categories) ? raw.categories : DEFAULT_CATEGORIES,
3575
4769
  actions: Array.isArray(raw.actions) ? raw.actions : [],
3576
4770
  features: Array.isArray(raw.features) ? raw.features : [],
4771
+ taskOptionDefaults: raw.taskOptionDefaults && typeof raw.taskOptionDefaults === "object" ? raw.taskOptionDefaults : {},
3577
4772
  sessions: Array.isArray(raw.sessions) ? raw.sessions.map(migrateSession) : []
3578
4773
  };
3579
4774
  } catch {
@@ -3620,7 +4815,7 @@ const ALLOWED_TOOLS = [
3620
4815
  "Bash(gh pr diff:*)"
3621
4816
  ].join(",");
3622
4817
  const SEVERITIES = ["info", "warning", "critical"];
3623
- function gitHead(folder) {
4818
+ function gitHead$1(folder) {
3624
4819
  return new Promise((resolve) => {
3625
4820
  child_process.execFile(
3626
4821
  "git",
@@ -3713,7 +4908,7 @@ class SentinelService {
3713
4908
  if (this.polling.has(session.id) || !fs.existsSync(session.folder)) return;
3714
4909
  this.polling.add(session.id);
3715
4910
  try {
3716
- const head = await gitHead(session.folder);
4911
+ const head = await gitHead$1(session.folder);
3717
4912
  if (!head) return;
3718
4913
  const previous = this.heads.get(session.id);
3719
4914
  this.heads.set(session.id, head);
@@ -3876,14 +5071,14 @@ function parseAgentOutput(stdout) {
3876
5071
  if (!text) return { summary: "", findings: [], error: "The agent produced no output." };
3877
5072
  return { summary: text.slice(0, 500), findings: [] };
3878
5073
  }
3879
- const SETTINGS_REL = ".claude/settings.local.json";
5074
+ const SETTINGS_REL$1 = ".claude/settings.local.json";
3880
5075
  const MCP_REL = ".mcp.json";
3881
- function writeJsonAtomic(file, value) {
5076
+ function writeJsonAtomic$1(file, value) {
3882
5077
  const tmp = file + ".tmp";
3883
5078
  fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf8");
3884
5079
  fs.renameSync(tmp, file);
3885
5080
  }
3886
- function readJson(file) {
5081
+ function readJson$1(file) {
3887
5082
  if (!fs.existsSync(file)) return {};
3888
5083
  try {
3889
5084
  const v = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -3893,9 +5088,9 @@ function readJson(file) {
3893
5088
  }
3894
5089
  }
3895
5090
  function applySettings(folder, category, allSkillNames, allManagedServerNames) {
3896
- const file = path.join(folder, SETTINGS_REL);
5091
+ const file = path.join(folder, SETTINGS_REL$1);
3897
5092
  const existed = fs.existsSync(file);
3898
- const settings = readJson(file);
5093
+ const settings = readJson$1(file);
3899
5094
  const overrides = settings.skillOverrides && typeof settings.skillOverrides === "object" ? { ...settings.skillOverrides } : {};
3900
5095
  const enabled = new Set(category?.enabledSkills ?? []);
3901
5096
  for (const name of allSkillNames) {
@@ -3918,19 +5113,19 @@ function applySettings(folder, category, allSkillNames, allManagedServerNames) {
3918
5113
  else delete settings.enabledMcpjsonServers;
3919
5114
  if (!existed && Object.keys(settings).length === 0) return;
3920
5115
  fs.mkdirSync(path.join(folder, ".claude"), { recursive: true });
3921
- writeJsonAtomic(file, settings);
5116
+ writeJsonAtomic$1(file, settings);
3922
5117
  }
3923
5118
  function applyMcp(folder, category, allManagedServerNames) {
3924
5119
  const file = path.join(folder, MCP_REL);
3925
5120
  const existed = fs.existsSync(file);
3926
- const root2 = readJson(file);
5121
+ const root2 = readJson$1(file);
3927
5122
  const servers = root2.mcpServers && typeof root2.mcpServers === "object" ? { ...root2.mcpServers } : {};
3928
5123
  for (const name of allManagedServerNames) delete servers[name];
3929
5124
  if (category) for (const s of category.mcpServers) servers[s.name] = s.config;
3930
5125
  if (Object.keys(servers).length > 0) root2.mcpServers = servers;
3931
5126
  else delete root2.mcpServers;
3932
5127
  if (!existed && Object.keys(root2).length === 0) return;
3933
- writeJsonAtomic(file, root2);
5128
+ writeJsonAtomic$1(file, root2);
3934
5129
  }
3935
5130
  function ensureGitExclude(folder) {
3936
5131
  const file = excludeFilePathSync(folder);
@@ -3943,7 +5138,7 @@ function ensureGitExclude(folder) {
3943
5138
  return;
3944
5139
  }
3945
5140
  const lines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
3946
- const wanted = [SETTINGS_REL, MCP_REL].filter((p) => !lines.has(p));
5141
+ const wanted = [SETTINGS_REL$1, MCP_REL].filter((p) => !lines.has(p));
3947
5142
  if (wanted.length === 0) return;
3948
5143
  try {
3949
5144
  fs.mkdirSync(infoDir, { recursive: true });
@@ -4092,9 +5287,10 @@ const STATUS_PRIORITY = [
4092
5287
  "exited"
4093
5288
  ];
4094
5289
  class SessionManager {
4095
- constructor(persistence2, fs2, getWin2) {
5290
+ constructor(persistence2, fs2, tokenEff, getWin2) {
4096
5291
  this.persistence = persistence2;
4097
5292
  this.fs = fs2;
5293
+ this.tokenEff = tokenEff;
4098
5294
  this.getWin = getWin2;
4099
5295
  }
4100
5296
  /** Keyed by terminal id, across all sessions. */
@@ -4153,6 +5349,7 @@ class SessionManager {
4153
5349
  skillNames,
4154
5350
  this.managedServerNames()
4155
5351
  );
5352
+ this.tokenEff.apply(config);
4156
5353
  }
4157
5354
  sessionOfTerminal(terminalId) {
4158
5355
  return this.state.sessions.find((s) => s.terminals.some((t) => t.id === terminalId));
@@ -4199,6 +5396,7 @@ class SessionManager {
4199
5396
  }
4200
5397
  this.fs.stop(id);
4201
5398
  this.clearQueueTimer(id);
5399
+ this.tokenEff.clearApplied(id);
4202
5400
  void deleteAllAttachments(id).catch(() => {
4203
5401
  });
4204
5402
  this.state.sessions = this.state.sessions.filter((s) => s.id !== id);
@@ -4248,6 +5446,12 @@ class SessionManager {
4248
5446
  if (!config) return { diff: "", binary: false, truncated: false };
4249
5447
  return gitFileDiff(config.folder, path2);
4250
5448
  }
5449
+ /** Local branches + default branch of a session's repo (base-branch picker). */
5450
+ async listBranches(sessionId) {
5451
+ const config = this.getConfig(sessionId);
5452
+ if (!config) return { branches: [], current: null, defaultBranch: null };
5453
+ return listBranches(config.folder);
5454
+ }
4251
5455
  /**
4252
5456
  * Initialize a git repository in a session's folder (so a non-repo session can
4253
5457
  * host parallel tasks). Returns the resulting git facts; throws git's message
@@ -4333,7 +5537,8 @@ class SessionManager {
4333
5537
  kind: "claude",
4334
5538
  title: "claude",
4335
5539
  order: 0,
4336
- claudeArgs: [],
5540
+ // A model picked for the task pins its claude via --model; absent = CLI default.
5541
+ claudeArgs: opts.model ? ["--model", opts.model] : [],
4337
5542
  startMode: "fresh"
4338
5543
  };
4339
5544
  const config = {
@@ -4619,10 +5824,26 @@ ${commit.output}` };
4619
5824
  event.url = pr.url;
4620
5825
  event.output = pr.output;
4621
5826
  } else {
4622
- const merge = await this.mergeWorktree(sessionId, true);
4623
- event.ok = merge.ok;
4624
- event.conflict = merge.conflict;
4625
- event.output = merge.output;
5827
+ const baseDirty = await dirtyCount(wt.baseFolder) ?? 0;
5828
+ const conflicts = baseDirty > 0 ? null : await mergeConflictFiles(wt.baseFolder, wt.branch, wt.baseBranch).catch(
5829
+ () => null
5830
+ );
5831
+ if (baseDirty > 0) {
5832
+ event.ok = false;
5833
+ 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.`;
5834
+ } else if (conflicts && conflicts.length > 0) {
5835
+ event.ok = false;
5836
+ event.conflict = true;
5837
+ event.output = `Auto-merge skipped: merging "${wt.branch}" into "${wt.baseBranch}" would conflict in:
5838
+ ` + conflicts.map((f) => ` • ${f}`).join("\n") + `
5839
+
5840
+ The base repo was left untouched — merge from the sidebar to resolve.`;
5841
+ } else {
5842
+ const merge = await this.mergeWorktree(sessionId, true);
5843
+ event.ok = merge.ok;
5844
+ event.conflict = merge.conflict;
5845
+ event.output = merge.output;
5846
+ }
4626
5847
  }
4627
5848
  const fresh = this.getConfig(sessionId);
4628
5849
  if (fresh?.worktree) {
@@ -4697,6 +5918,7 @@ ${err.message}`
4697
5918
  }
4698
5919
  this.clearQueueTimer(sessionId);
4699
5920
  this.clearAutoCompleteTimer(sessionId);
5921
+ this.tokenEff.clearApplied(sessionId);
4700
5922
  this.worktreeWorked.delete(sessionId);
4701
5923
  this.autoCompleteInFlight.delete(sessionId);
4702
5924
  this.state.sessions = this.state.sessions.filter((s) => s.id !== sessionId);
@@ -5030,6 +6252,7 @@ ${err.message}`
5030
6252
  this.fs.stopAll();
5031
6253
  }
5032
6254
  spawnTerminal(config, terminal, mode, history) {
6255
+ const te = terminal.kind === "claude" ? this.tokenEff.envFor(config) : { set: {}, drop: [] };
5033
6256
  const session = new PtySession(
5034
6257
  terminal,
5035
6258
  config.folder,
@@ -5044,11 +6267,13 @@ ${err.message}`
5044
6267
  // Snapshot lazily at write time — the store throttles to ~1 write/s.
5045
6268
  onOutput: (id) => this.scrollback.markDirty(id, () => session.tail(SCROLLBACK_MAX_BYTES))
5046
6269
  },
5047
- config.env ?? {}
6270
+ { ...te.set, ...config.env ?? {} },
6271
+ te.drop
5048
6272
  );
5049
6273
  if (history) session.seedHistory(history + SCROLLBACK_DIVIDER);
5050
6274
  this.ptys.set(terminal.id, session);
5051
6275
  session.spawn(mode);
6276
+ if (terminal.kind === "claude") this.tokenEff.markApplied(config);
5052
6277
  }
5053
6278
  handleStatus(terminalId, status) {
5054
6279
  const win2 = this.getWin();
@@ -5147,7 +6372,8 @@ ${err.message}`
5147
6372
  lastOutputAt: pty2?.detector.lastOutput ?? 0,
5148
6373
  exitCode: pty2?.exitCode ?? null,
5149
6374
  statusSince,
5150
- watchdog: pty2?.alive ? this.watchdogAlert(terminal, status, statusSince) : null
6375
+ watchdog: pty2?.alive ? this.watchdogAlert(terminal, status, statusSince) : null,
6376
+ outputChars: pty2?.outputChars ?? 0
5151
6377
  };
5152
6378
  }
5153
6379
  /**
@@ -5187,6 +6413,817 @@ ${err.message}`
5187
6413
  this.getWin()?.webContents.send("session:changed");
5188
6414
  }
5189
6415
  }
6416
+ const OUTPUT_FILTER = String.raw`#!/usr/bin/env node
6417
+ // Maestro Token Efficiency — built-in output filter (auto-generated, do not edit).
6418
+ import { appendFileSync } from 'node:fs'
6419
+
6420
+ function arg(name) {
6421
+ const i = process.argv.indexOf(name)
6422
+ return i >= 0 ? process.argv[i + 1] : null
6423
+ }
6424
+ const statsFile = arg('--stats')
6425
+
6426
+ const HEAD_LINES = 120
6427
+ const TAIL_LINES = 80
6428
+ const KEEP_MAX = 60
6429
+ const KEEP_ERR = /\b(error|fail|failed|failure|exception|fatal|panic|traceback)\b/i
6430
+ const KEEP_WARN = /\b(warn|warning)\b/i
6431
+
6432
+ function compress(raw) {
6433
+ // Strip ANSI CSI/OSC sequences; keep only the final state of \r-redrawn lines.
6434
+ const text = raw
6435
+ .replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, '')
6436
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
6437
+ const lines = []
6438
+ for (const rawLine of text.split('\n')) {
6439
+ const line = rawLine.includes('\r') ? rawLine.slice(rawLine.lastIndexOf('\r') + 1) : rawLine
6440
+ lines.push(line.replace(/\s+$/, ''))
6441
+ }
6442
+ // Collapse runs of identical lines and squeeze blank-line runs.
6443
+ const collapsed = []
6444
+ let i = 0
6445
+ while (i < lines.length) {
6446
+ let j = i + 1
6447
+ while (j < lines.length && lines[j] === lines[i]) j++
6448
+ const n = j - i
6449
+ if (lines[i].trim() === '') {
6450
+ if (collapsed.length > 0 && collapsed[collapsed.length - 1] !== '') collapsed.push('')
6451
+ } else if (n > 2) {
6452
+ collapsed.push(lines[i] + ' [repeated ' + n + 'x]')
6453
+ } else {
6454
+ for (let k = 0; k < n; k++) collapsed.push(lines[i])
6455
+ }
6456
+ i = j
6457
+ }
6458
+ if (collapsed.length <= HEAD_LINES + TAIL_LINES + 20) return collapsed.join('\n')
6459
+ const head = collapsed.slice(0, HEAD_LINES)
6460
+ const tail = collapsed.slice(collapsed.length - TAIL_LINES)
6461
+ const middle = collapsed.slice(HEAD_LINES, collapsed.length - TAIL_LINES)
6462
+ // Errors outrank warnings for the keep budget — a wall of deprecation
6463
+ // warnings must never crowd out the one failure line.
6464
+ const errs = []
6465
+ const warns = []
6466
+ for (const line of middle) {
6467
+ if (KEEP_ERR.test(line)) {
6468
+ if (errs.length < KEEP_MAX) errs.push(line)
6469
+ } else if (KEEP_WARN.test(line)) {
6470
+ if (warns.length < KEEP_MAX) warns.push(line)
6471
+ }
6472
+ }
6473
+ const kept = errs.concat(warns.slice(0, Math.max(0, KEEP_MAX - errs.length)))
6474
+ const omitted = middle.length - kept.length
6475
+ return head
6476
+ .concat(
6477
+ '',
6478
+ '... ' + omitted + ' lines omitted by Maestro token filter' +
6479
+ (kept.length ? ' (errors/warnings kept below)' : '') + ' ...',
6480
+ ''
6481
+ )
6482
+ .concat(kept.length ? kept.concat('') : [])
6483
+ .concat(tail)
6484
+ .join('\n')
6485
+ }
6486
+
6487
+ const chunks = []
6488
+ process.stdin.on('data', (c) => chunks.push(c))
6489
+ process.stdin.on('end', () => {
6490
+ const raw = Buffer.concat(chunks).toString('utf8')
6491
+ let out
6492
+ try {
6493
+ out = compress(raw)
6494
+ } catch {
6495
+ out = raw // never lose output to a filter bug
6496
+ }
6497
+ process.stdout.write(out)
6498
+ if (statsFile) {
6499
+ try {
6500
+ appendFileSync(
6501
+ statsFile,
6502
+ JSON.stringify({ at: Date.now(), cwd: process.cwd(), kind: 'filter', orig: raw.length, out: out.length }) + '\n'
6503
+ )
6504
+ } catch {}
6505
+ }
6506
+ })
6507
+ `;
6508
+ const BASH_COMPRESS = String.raw`#!/usr/bin/env node
6509
+ // Maestro Token Efficiency — Bash command compression hook (auto-generated, do not edit).
6510
+ import { appendFileSync, readFileSync } from 'node:fs'
6511
+
6512
+ function arg(name) {
6513
+ const i = process.argv.indexOf(name)
6514
+ return i >= 0 ? process.argv[i + 1] : null
6515
+ }
6516
+ const statsFile = arg('--stats')
6517
+ const filter = arg('--filter')
6518
+ const rtk = arg('--rtk') === '1'
6519
+
6520
+ let input
6521
+ try {
6522
+ input = JSON.parse(readFileSync(0, 'utf8'))
6523
+ } catch {
6524
+ process.exit(0)
6525
+ }
6526
+ if (!input || input.tool_name !== 'Bash' || !input.tool_input) process.exit(0)
6527
+ const command = String(input.tool_input.command || '').trim()
6528
+ if (!command) process.exit(0)
6529
+
6530
+ // Already shaped/structured output — don't double-process.
6531
+ if (/[|<>]/.test(command)) process.exit(0)
6532
+ if (/\brtk\b/.test(command) || command.includes('output-filter.mjs')) process.exit(0)
6533
+
6534
+ const NOISY = [
6535
+ /^git (status|log|diff|show|fetch|pull|blame)\b/,
6536
+ /^(npm|pnpm|yarn|bun) (install|ci|i)\b/,
6537
+ /^(npm|pnpm|yarn|bun) (run )?(build|test|lint|typecheck|tsc)\b/,
6538
+ /^npx (tsc|jest|vitest|eslint|playwright|mocha)\b/,
6539
+ /^(cargo|go) (build|test|check|vet)\b/,
6540
+ /^(mvn|gradle|gradlew|\.\/gradlew)\b/,
6541
+ /^(pytest|tox|tsc|make)\b/,
6542
+ /^dotnet (build|test|restore)\b/,
6543
+ /^pip3? install\b/
6544
+ ]
6545
+ if (!NOISY.some((re) => re.test(command))) process.exit(0)
6546
+
6547
+ let updated = null
6548
+ let kind = null
6549
+ if (rtk && /^git /.test(command)) {
6550
+ updated = 'rtk ' + command
6551
+ kind = 'rtk'
6552
+ } else if (filter) {
6553
+ updated =
6554
+ 'set -o pipefail; { ' + command + '; } 2>&1 | node "' + filter + '"' +
6555
+ (statsFile ? ' --stats "' + statsFile + '"' : '')
6556
+ kind = 'wrap'
6557
+ }
6558
+ if (!updated) process.exit(0)
6559
+
6560
+ if (statsFile && kind === 'rtk') {
6561
+ try {
6562
+ appendFileSync(
6563
+ statsFile,
6564
+ JSON.stringify({ at: Date.now(), cwd: process.cwd(), kind: 'rtk' }) + '\n'
6565
+ )
6566
+ } catch {}
6567
+ }
6568
+ process.stdout.write(
6569
+ JSON.stringify({
6570
+ hookSpecificOutput: {
6571
+ hookEventName: 'PreToolUse',
6572
+ permissionDecision: 'allow',
6573
+ permissionDecisionReason: 'Maestro token efficiency: noisy command output is compressed (' + kind + ')',
6574
+ updatedInput: Object.assign({}, input.tool_input, { command: updated })
6575
+ }
6576
+ })
6577
+ )
6578
+ `;
6579
+ const READ_GUARD = String.raw`#!/usr/bin/env node
6580
+ // Maestro Token Efficiency — large-read guard hook (auto-generated, do not edit).
6581
+ import { appendFileSync, readFileSync, statSync } from 'node:fs'
6582
+
6583
+ function arg(name) {
6584
+ const i = process.argv.indexOf(name)
6585
+ return i >= 0 ? process.argv[i + 1] : null
6586
+ }
6587
+ const statsFile = arg('--stats')
6588
+ const maxKB = Math.max(8, parseInt(arg('--max-kb') || '256', 10) || 256)
6589
+
6590
+ let input
6591
+ try {
6592
+ input = JSON.parse(readFileSync(0, 'utf8'))
6593
+ } catch {
6594
+ process.exit(0)
6595
+ }
6596
+ if (!input || input.tool_name !== 'Read' || !input.tool_input) process.exit(0)
6597
+ const file = String(input.tool_input.file_path || '')
6598
+ if (!file) process.exit(0)
6599
+ // A targeted slice is deliberate — allow it.
6600
+ if (input.tool_input.offset || input.tool_input.limit) process.exit(0)
6601
+
6602
+ const base = (file.split(/[\\/]/).pop() || '').toLowerCase()
6603
+ const norm = file.replace(/\\/g, '/').toLowerCase()
6604
+ const LOCKFILES = [
6605
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'bun.lock',
6606
+ 'cargo.lock', 'poetry.lock', 'pipfile.lock', 'uv.lock', 'composer.lock', 'gemfile.lock',
6607
+ 'go.sum', 'flake.lock'
6608
+ ]
6609
+ const sink =
6610
+ LOCKFILES.indexOf(base) >= 0 ||
6611
+ /\/(node_modules|dist|build|out|target|\.venv|__pycache__|coverage)\//.test(norm) ||
6612
+ /\.(log|jsonl|map)$/.test(base) ||
6613
+ base.includes('.min.')
6614
+ if (!sink) process.exit(0)
6615
+
6616
+ let size = 0
6617
+ try {
6618
+ size = statSync(file).size
6619
+ } catch {
6620
+ process.exit(0)
6621
+ }
6622
+ if (size <= maxKB * 1024) process.exit(0)
6623
+
6624
+ if (statsFile) {
6625
+ try {
6626
+ appendFileSync(
6627
+ statsFile,
6628
+ JSON.stringify({ at: Date.now(), cwd: process.cwd(), kind: 'blocked-read', bytes: size }) + '\n'
6629
+ )
6630
+ } catch {}
6631
+ }
6632
+ const sizeKB = Math.round(size / 1024)
6633
+ process.stdout.write(
6634
+ JSON.stringify({
6635
+ hookSpecificOutput: {
6636
+ hookEventName: 'PreToolUse',
6637
+ permissionDecision: 'deny',
6638
+ permissionDecisionReason:
6639
+ 'Maestro token guard: "' + file + '" is ' + sizeKB + ' KB of low-signal content ' +
6640
+ '(lockfile/log/build artifact). Reading it whole would waste a large amount of context. ' +
6641
+ 'Use Grep to find the specific entry you need, or Read with offset/limit for a targeted slice.'
6642
+ }
6643
+ })
6644
+ )
6645
+ `;
6646
+ const SESSION_CONTEXT = String.raw`#!/usr/bin/env node
6647
+ // Maestro Token Efficiency — repo-map session context hook (auto-generated, do not edit).
6648
+ import { existsSync, readFileSync } from 'node:fs'
6649
+ import { join } from 'node:path'
6650
+
6651
+ try {
6652
+ const map = join(process.cwd(), '.claude', 'maestro-repo-map.md')
6653
+ if (existsSync(map)) {
6654
+ const text = readFileSync(map, 'utf8').trim()
6655
+ if (text) {
6656
+ process.stdout.write(
6657
+ 'Repo symbol map (generated by Maestro). Use it to jump straight to the right file/symbol ' +
6658
+ 'with Grep or a targeted Read instead of reading whole files:\n\n' + text + '\n'
6659
+ )
6660
+ }
6661
+ }
6662
+ } catch {}
6663
+ `;
6664
+ const SCRIPT_FILES = {
6665
+ outputFilter: "output-filter.mjs",
6666
+ bashCompress: "bash-compress.mjs",
6667
+ readGuard: "read-guard.mjs",
6668
+ sessionContext: "session-context.mjs"
6669
+ };
6670
+ function ensureScripts(scriptsDir) {
6671
+ fs.mkdirSync(scriptsDir, { recursive: true });
6672
+ const sources = {
6673
+ outputFilter: OUTPUT_FILTER,
6674
+ bashCompress: BASH_COMPRESS,
6675
+ readGuard: READ_GUARD,
6676
+ sessionContext: SESSION_CONTEXT
6677
+ };
6678
+ const out = {};
6679
+ for (const key of Object.keys(SCRIPT_FILES)) {
6680
+ const path$1 = path.join(scriptsDir, SCRIPT_FILES[key]);
6681
+ fs.writeFileSync(path$1, sources[key], "utf8");
6682
+ out[key] = path$1;
6683
+ }
6684
+ return out;
6685
+ }
6686
+ const IS_WIN = process.platform === "win32";
6687
+ const REPO_MAP_REL = ".claude/maestro-repo-map.md";
6688
+ const SETTINGS_REL = ".claude/settings.local.json";
6689
+ const HEAD_POLL_MS = 6e4;
6690
+ const REPO_MAP_MAX_BYTES = 24 * 1024;
6691
+ const MAX_SYMBOLS_PER_FILE = 15;
6692
+ const MAP_FILE_MAX_BYTES = 512 * 1024;
6693
+ const STATS_ROTATE_BYTES = 2 * 1024 * 1024;
6694
+ const BLOCKED_READ_MAX_TOKENS = 5e4;
6695
+ const EXTRACTORS = [
6696
+ {
6697
+ exts: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
6698
+ patterns: [
6699
+ /^export\s+(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum|const|let)\s+([A-Za-z_$][\w$]*)/gm,
6700
+ /^(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/gm,
6701
+ /^(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/gm
6702
+ ]
6703
+ },
6704
+ {
6705
+ exts: [".py"],
6706
+ patterns: [/^(?:async\s+)?def\s+(\w+)/gm, /^class\s+(\w+)/gm]
6707
+ },
6708
+ {
6709
+ exts: [".java", ".cs", ".kt"],
6710
+ patterns: [
6711
+ /^\s*(?:public|protected|internal)?\s*(?:static\s+|final\s+|sealed\s+|abstract\s+|data\s+)*(?:class|interface|enum|record)\s+(\w+)/gm,
6712
+ /^\s*(?:public|protected)\s+(?:static\s+)?[\w<>[\],.\s?]+?\s+(\w+)\s*\(/gm
6713
+ ]
6714
+ },
6715
+ {
6716
+ exts: [".go"],
6717
+ patterns: [/^func\s+(?:\([^)]*\)\s+)?(\w+)/gm, /^type\s+(\w+)/gm]
6718
+ },
6719
+ {
6720
+ exts: [".rs"],
6721
+ patterns: [
6722
+ /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)/gm,
6723
+ /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait)\s+(\w+)/gm
6724
+ ]
6725
+ },
6726
+ {
6727
+ exts: [".rb"],
6728
+ patterns: [/^\s*(?:def|class|module)\s+([\w.?!]+)/gm]
6729
+ },
6730
+ {
6731
+ exts: [".php"],
6732
+ patterns: [/function\s+(\w+)/gm, /^\s*(?:abstract\s+|final\s+)?class\s+(\w+)/gm]
6733
+ }
6734
+ ];
6735
+ const EXT_TO_PATTERNS = /* @__PURE__ */ new Map();
6736
+ for (const group of EXTRACTORS) for (const ext of group.exts) EXT_TO_PATTERNS.set(ext, group.patterns);
6737
+ function which(name) {
6738
+ const out = IS_WIN ? child_process.spawnSync("where.exe", [name], { encoding: "utf8" }) : child_process.spawnSync("which", [name], { encoding: "utf8" });
6739
+ if (out.status !== 0 || !out.stdout) return null;
6740
+ return out.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0] ?? null;
6741
+ }
6742
+ function readJson(file) {
6743
+ if (!fs.existsSync(file)) return {};
6744
+ try {
6745
+ const v = JSON.parse(fs.readFileSync(file, "utf8"));
6746
+ return v && typeof v === "object" ? v : {};
6747
+ } catch {
6748
+ return {};
6749
+ }
6750
+ }
6751
+ function writeJsonAtomic(file, value) {
6752
+ const tmp = file + ".tmp";
6753
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf8");
6754
+ fs.renameSync(tmp, file);
6755
+ }
6756
+ function defined(o) {
6757
+ const out = {};
6758
+ if (!o) return out;
6759
+ for (const [k, v] of Object.entries(o)) {
6760
+ if (v !== void 0) out[k] = v;
6761
+ }
6762
+ return out;
6763
+ }
6764
+ function underFolder(path2, folder) {
6765
+ const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
6766
+ const a = norm(path2);
6767
+ const b = norm(folder);
6768
+ return a === b || a.startsWith(b + "/");
6769
+ }
6770
+ function gitHead(folder) {
6771
+ return new Promise((resolve) => {
6772
+ child_process.execFile(
6773
+ "git",
6774
+ ["rev-parse", "HEAD"],
6775
+ { cwd: folder, windowsHide: true },
6776
+ (err, stdout) => resolve(err ? null : stdout.trim() || null)
6777
+ );
6778
+ });
6779
+ }
6780
+ function ensureMapExcluded(folder) {
6781
+ const file = excludeFilePathSync(folder);
6782
+ if (!file) return;
6783
+ try {
6784
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
6785
+ const lines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
6786
+ if (lines.has(REPO_MAP_REL)) return;
6787
+ fs.mkdirSync(path.dirname(file), { recursive: true });
6788
+ const suffix = current.length && !current.endsWith("\n") ? "\n" : "";
6789
+ fs.writeFileSync(file, current + suffix + REPO_MAP_REL + "\n", "utf8");
6790
+ } catch {
6791
+ }
6792
+ }
6793
+ class TokenEfficiencyService {
6794
+ constructor(persistence2) {
6795
+ this.persistence = persistence2;
6796
+ try {
6797
+ fs.mkdirSync(this.baseDir, { recursive: true });
6798
+ this.scripts = ensureScripts(path.join(this.baseDir, "scripts"));
6799
+ } catch (err) {
6800
+ console.error("Token efficiency: failed to write hook scripts", err);
6801
+ this.scripts = null;
6802
+ }
6803
+ }
6804
+ baseDir = path.join(electron.app.getPath("userData"), "token-efficiency");
6805
+ statsFile = path.join(this.baseDir, "stats.jsonl");
6806
+ scripts = null;
6807
+ rtkPath;
6808
+ // undefined = not probed yet
6809
+ nodePath;
6810
+ /** Effective config each session's claude was last spawned with. */
6811
+ applied = /* @__PURE__ */ new Map();
6812
+ /** Repo-map facts per repo folder (the folder the map was generated in). */
6813
+ repoMaps = /* @__PURE__ */ new Map();
6814
+ /** Last seen HEAD per session folder, for change-driven map refresh. */
6815
+ lastHead = /* @__PURE__ */ new Map();
6816
+ /** Folders with a map generation in flight (dedupe). */
6817
+ generating = /* @__PURE__ */ new Set();
6818
+ statsCache = null;
6819
+ pollTimer = null;
6820
+ /** Begin the git-change poll that keeps repo maps fresh. Idempotent. */
6821
+ start() {
6822
+ if (this.pollTimer) return;
6823
+ this.pollTimer = setInterval(() => void this.pollGitChanges(), HEAD_POLL_MS);
6824
+ }
6825
+ dispose() {
6826
+ if (this.pollTimer) clearInterval(this.pollTimer);
6827
+ this.pollTimer = null;
6828
+ }
6829
+ // ---------- config resolution ----------
6830
+ /** The override key a session's repo resolves to (worktree tasks → base repo). */
6831
+ repoKeyOf(config) {
6832
+ return config.worktree?.baseFolder ?? config.folder;
6833
+ }
6834
+ /** Global ⊕ repo override ⊕ session override. */
6835
+ resolveEffective(config) {
6836
+ const settings = this.persistence.state.settings;
6837
+ const repoOv = settings.tokenEfficiencyRepoOverrides[this.repoKeyOf(config)];
6838
+ return {
6839
+ ...settings.tokenEfficiency,
6840
+ ...defined(repoOv),
6841
+ ...defined(config.tokenEfficiency)
6842
+ };
6843
+ }
6844
+ /** Probe for external tools (cached; `refresh` re-probes). */
6845
+ detectTools(refresh = false) {
6846
+ if (refresh || this.rtkPath === void 0) this.rtkPath = which("rtk");
6847
+ if (refresh || this.nodePath === void 0) this.nodePath = which("node");
6848
+ return {
6849
+ rtk: { found: this.rtkPath !== null, path: this.rtkPath },
6850
+ nodeFound: this.nodePath !== null
6851
+ };
6852
+ }
6853
+ // ---------- materialization (runs before claude spawns) ----------
6854
+ /**
6855
+ * Materialize the session's effective config into its repo: write/remove our
6856
+ * managed hook entries and kick a repo-map (re)generation. Idempotent and
6857
+ * reversible — with the toolkit off every trace is removed again. Never
6858
+ * throws (a failure must not block a session launch).
6859
+ */
6860
+ apply(config) {
6861
+ if (!fs.existsSync(config.folder)) return;
6862
+ const effective = this.resolveEffective(config);
6863
+ try {
6864
+ this.applyHooks(config.folder, effective);
6865
+ } catch (err) {
6866
+ console.error("Token efficiency: applying hooks failed for", config.folder, err);
6867
+ }
6868
+ if (effective.enabled && effective.codeGraph) {
6869
+ void this.ensureRepoMap(config.folder, effective, false);
6870
+ } else {
6871
+ this.removeRepoMap(config.folder);
6872
+ }
6873
+ }
6874
+ /** Env overlay for a claude spawn: variables to set and to drop. */
6875
+ envFor(config) {
6876
+ const effective = this.resolveEffective(config);
6877
+ const set = {};
6878
+ const drop = [];
6879
+ if (!effective.enabled) return { set, drop };
6880
+ if (effective.truncationHooks) {
6881
+ set.BASH_MAX_OUTPUT_LENGTH = String(effective.bashMaxOutputChars);
6882
+ set.MAX_MCP_OUTPUT_TOKENS = String(effective.mcpMaxOutputTokens);
6883
+ }
6884
+ if (effective.promptCachingHints) drop.push("DISABLE_PROMPT_CACHING");
6885
+ return { set, drop };
6886
+ }
6887
+ /** Record what a session's claude was actually spawned with (for drift). */
6888
+ markApplied(config) {
6889
+ this.applied.set(config.id, this.resolveEffective(config));
6890
+ }
6891
+ clearApplied(sessionId) {
6892
+ this.applied.delete(sessionId);
6893
+ }
6894
+ /**
6895
+ * Rewrite our managed hook entries in the repo's settings.local.json. Ours
6896
+ * are recognized by the scripts-dir path inside the command string; foreign
6897
+ * hooks are preserved untouched (same managed-namespace contract as
6898
+ * ContextProfile).
6899
+ */
6900
+ applyHooks(folder, effective) {
6901
+ const file = path.join(folder, SETTINGS_REL);
6902
+ const existed = fs.existsSync(file);
6903
+ const settings = readJson(file);
6904
+ const marker = this.scripts ? path.dirname(this.scripts.outputFilter) : null;
6905
+ const hooks = settings.hooks && typeof settings.hooks === "object" ? { ...settings.hooks } : {};
6906
+ const isOurs = (entry) => {
6907
+ const hookList = entry?.hooks;
6908
+ if (!Array.isArray(hookList) || !marker) return false;
6909
+ return hookList.some((h) => {
6910
+ const cmd = h?.command;
6911
+ return typeof cmd === "string" && cmd.includes(marker);
6912
+ });
6913
+ };
6914
+ for (const event of ["PreToolUse", "SessionStart"]) {
6915
+ const entries = Array.isArray(hooks[event]) ? hooks[event].filter((e) => !isOurs(e)) : [];
6916
+ if (entries.length > 0) hooks[event] = entries;
6917
+ else delete hooks[event];
6918
+ }
6919
+ const active = effective.enabled && this.scripts && this.detectTools().nodeFound;
6920
+ if (active && this.scripts) {
6921
+ const node = "node";
6922
+ const q = (p) => '"' + p + '"';
6923
+ const pre = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
6924
+ if (effective.outputCompression) {
6925
+ const rtkFlag = this.detectTools().rtk.found ? "1" : "0";
6926
+ pre.push({
6927
+ matcher: "Bash",
6928
+ hooks: [
6929
+ {
6930
+ type: "command",
6931
+ command: node + " " + q(this.scripts.bashCompress) + " --stats " + q(this.statsFile) + " --filter " + q(this.scripts.outputFilter) + " --rtk " + rtkFlag,
6932
+ timeout: 10
6933
+ }
6934
+ ]
6935
+ });
6936
+ }
6937
+ if (effective.truncationHooks) {
6938
+ pre.push({
6939
+ matcher: "Read",
6940
+ hooks: [
6941
+ {
6942
+ type: "command",
6943
+ command: node + " " + q(this.scripts.readGuard) + " --stats " + q(this.statsFile) + " --max-kb " + effective.largeReadMaxKB,
6944
+ timeout: 10
6945
+ }
6946
+ ]
6947
+ });
6948
+ }
6949
+ if (pre.length > 0) hooks.PreToolUse = pre;
6950
+ if (effective.codeGraph) {
6951
+ const start = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
6952
+ start.push({
6953
+ hooks: [
6954
+ { type: "command", command: node + " " + q(this.scripts.sessionContext), timeout: 10 }
6955
+ ]
6956
+ });
6957
+ hooks.SessionStart = start;
6958
+ }
6959
+ }
6960
+ if (Object.keys(hooks).length > 0) settings.hooks = hooks;
6961
+ else delete settings.hooks;
6962
+ if (!existed && Object.keys(settings).length === 0) return;
6963
+ fs.mkdirSync(path.join(folder, ".claude"), { recursive: true });
6964
+ writeJsonAtomic(file, settings);
6965
+ }
6966
+ // ---------- repo map (code graph) ----------
6967
+ /**
6968
+ * (Re)generate a session folder's repo map when missing or its HEAD moved
6969
+ * (`force` regenerates regardless). The map is written into the repo for the
6970
+ * SessionStart hook to pick up, and kept out of git via info/exclude.
6971
+ */
6972
+ async ensureRepoMap(folder, effective, force) {
6973
+ if (this.generating.has(folder)) return this.repoMaps.get(folder) ?? null;
6974
+ const mapPath = path.join(folder, REPO_MAP_REL);
6975
+ const head = await gitHead(folder);
6976
+ const known = this.repoMaps.get(folder);
6977
+ if (!force && known && fs.existsSync(mapPath) && head && this.lastHead.get(folder) === head) {
6978
+ return known;
6979
+ }
6980
+ this.generating.add(folder);
6981
+ try {
6982
+ const info = this.generateRepoMap(folder, effective.repoMapMaxFiles);
6983
+ this.repoMaps.set(folder, info);
6984
+ if (head) this.lastHead.set(folder, head);
6985
+ ensureMapExcluded(folder);
6986
+ return info;
6987
+ } catch (err) {
6988
+ console.error("Token efficiency: repo map generation failed for", folder, err);
6989
+ return null;
6990
+ } finally {
6991
+ this.generating.delete(folder);
6992
+ }
6993
+ }
6994
+ /** Delete a folder's generated map (used when the code graph is toggled off). */
6995
+ removeRepoMap(folder) {
6996
+ this.repoMaps.delete(folder);
6997
+ this.lastHead.delete(folder);
6998
+ try {
6999
+ fs.rmSync(path.join(folder, REPO_MAP_REL), { force: true });
7000
+ } catch {
7001
+ }
7002
+ }
7003
+ /**
7004
+ * Walk the repo and produce a compact aider-style "path: symbols" map.
7005
+ * Regex extraction by design: dependency-free (no native tree-sitter) and
7006
+ * fast enough to run synchronously on spawn (caps bound the work).
7007
+ */
7008
+ generateRepoMap(folder, maxFiles) {
7009
+ const ignore = /* @__PURE__ */ new Set([
7010
+ ...this.persistence.state.settings.ignoreNames,
7011
+ ".git",
7012
+ "vendor",
7013
+ "coverage"
7014
+ ]);
7015
+ const files = [];
7016
+ const walk = (dir, depth) => {
7017
+ if (depth > 12 || files.length >= maxFiles) return;
7018
+ let entries;
7019
+ try {
7020
+ entries = fs.readdirSync(dir);
7021
+ } catch {
7022
+ return;
7023
+ }
7024
+ const subdirs = [];
7025
+ for (const entry of entries) {
7026
+ if (files.length >= maxFiles) return;
7027
+ if (ignore.has(entry) || entry.startsWith(".")) continue;
7028
+ const path$1 = path.join(dir, entry);
7029
+ let stat;
7030
+ try {
7031
+ stat = fs.statSync(path$1);
7032
+ } catch {
7033
+ continue;
7034
+ }
7035
+ if (stat.isDirectory()) {
7036
+ subdirs.push(path$1);
7037
+ } else if (stat.size <= MAP_FILE_MAX_BYTES) {
7038
+ const ext = entry.slice(entry.lastIndexOf(".")).toLowerCase();
7039
+ if (EXT_TO_PATTERNS.has(ext)) files.push(path$1);
7040
+ }
7041
+ }
7042
+ for (const sub of subdirs) walk(sub, depth + 1);
7043
+ };
7044
+ walk(folder, 0);
7045
+ const lines = [];
7046
+ let totalSymbols = 0;
7047
+ let bytes = 0;
7048
+ for (const path$1 of files) {
7049
+ let text;
7050
+ try {
7051
+ text = fs.readFileSync(path$1, "utf8");
7052
+ } catch {
7053
+ continue;
7054
+ }
7055
+ const ext = path$1.slice(path$1.lastIndexOf(".")).toLowerCase();
7056
+ const patterns = EXT_TO_PATTERNS.get(ext) ?? [];
7057
+ const symbols = [];
7058
+ const seen = /* @__PURE__ */ new Set();
7059
+ for (const pattern of patterns) {
7060
+ pattern.lastIndex = 0;
7061
+ let m;
7062
+ while (m = pattern.exec(text)) {
7063
+ const name = m[1];
7064
+ if (name && !seen.has(name)) {
7065
+ seen.add(name);
7066
+ symbols.push(name);
7067
+ }
7068
+ if (seen.size > MAX_SYMBOLS_PER_FILE * 2) break;
7069
+ }
7070
+ }
7071
+ if (symbols.length === 0) continue;
7072
+ const shown = symbols.slice(0, MAX_SYMBOLS_PER_FILE);
7073
+ const rel = path.relative(folder, path$1).replace(/\\/g, "/");
7074
+ const line = rel + ": " + shown.join(", ") + (symbols.length > shown.length ? ", …" : "");
7075
+ if (bytes + line.length > REPO_MAP_MAX_BYTES) break;
7076
+ lines.push(line);
7077
+ bytes += line.length + 1;
7078
+ totalSymbols += shown.length;
7079
+ }
7080
+ const content = lines.join("\n") + "\n";
7081
+ const mapPath = path.join(folder, REPO_MAP_REL);
7082
+ fs.mkdirSync(path.dirname(mapPath), { recursive: true });
7083
+ fs.writeFileSync(mapPath, content, "utf8");
7084
+ return { generatedAt: Date.now(), files: lines.length, symbols: totalSymbols, bytes: content.length };
7085
+ }
7086
+ /** Refresh maps for sessions whose repo HEAD moved since the last poll. */
7087
+ async pollGitChanges() {
7088
+ for (const config of this.persistence.state.sessions) {
7089
+ const effective = this.resolveEffective(config);
7090
+ if (!effective.enabled || !effective.codeGraph) continue;
7091
+ if (!fs.existsSync(config.folder)) continue;
7092
+ const head = await gitHead(config.folder);
7093
+ if (!head) continue;
7094
+ const prev = this.lastHead.get(config.folder);
7095
+ if (prev !== head) await this.ensureRepoMap(config.folder, effective, true);
7096
+ }
7097
+ }
7098
+ // ---------- savings stats (logged by the hook scripts) ----------
7099
+ loadStats() {
7100
+ let stat;
7101
+ try {
7102
+ stat = fs.statSync(this.statsFile);
7103
+ } catch {
7104
+ return [];
7105
+ }
7106
+ if (this.statsCache && this.statsCache.mtimeMs === stat.mtimeMs && this.statsCache.size === stat.size) {
7107
+ return this.statsCache.entries;
7108
+ }
7109
+ let raw;
7110
+ try {
7111
+ raw = fs.readFileSync(this.statsFile, "utf8");
7112
+ } catch {
7113
+ return [];
7114
+ }
7115
+ if (raw.length > STATS_ROTATE_BYTES) {
7116
+ const tail2 = raw.slice(-1048576);
7117
+ raw = tail2.slice(tail2.indexOf("\n") + 1);
7118
+ try {
7119
+ fs.writeFileSync(this.statsFile, raw, "utf8");
7120
+ } catch {
7121
+ }
7122
+ }
7123
+ const entries = [];
7124
+ for (const line of raw.split("\n")) {
7125
+ if (!line.trim()) continue;
7126
+ try {
7127
+ const e = JSON.parse(line);
7128
+ if (e && typeof e.cwd === "string" && typeof e.kind === "string") entries.push(e);
7129
+ } catch {
7130
+ }
7131
+ }
7132
+ try {
7133
+ const fresh = fs.statSync(this.statsFile);
7134
+ this.statsCache = { mtimeMs: fresh.mtimeMs, size: fresh.size, entries };
7135
+ } catch {
7136
+ this.statsCache = null;
7137
+ }
7138
+ return entries;
7139
+ }
7140
+ /** Savings attributed to one folder ('' aggregates everything). */
7141
+ savingsFor(folder) {
7142
+ const savings = {
7143
+ savedTokens: 0,
7144
+ rtkRewrites: 0,
7145
+ filteredCommands: 0,
7146
+ blockedReads: 0
7147
+ };
7148
+ for (const e of this.loadStats()) {
7149
+ if (folder && !underFolder(e.cwd, folder)) continue;
7150
+ if (e.kind === "filter") {
7151
+ savings.filteredCommands++;
7152
+ const saved = Math.max(0, (e.orig ?? 0) - (e.out ?? 0));
7153
+ savings.savedTokens += Math.round(saved / 4);
7154
+ } else if (e.kind === "rtk") {
7155
+ savings.rtkRewrites++;
7156
+ } else if (e.kind === "blocked-read") {
7157
+ savings.blockedReads++;
7158
+ savings.savedTokens += Math.min(Math.round((e.bytes ?? 0) / 4), BLOCKED_READ_MAX_TOKENS);
7159
+ }
7160
+ }
7161
+ return savings;
7162
+ }
7163
+ // ---------- status & settings mutation (IPC surface) ----------
7164
+ status(sessionId) {
7165
+ const config = this.persistence.state.sessions.find((s) => s.id === sessionId);
7166
+ if (!config) return null;
7167
+ const settings = this.persistence.state.settings;
7168
+ const effective = this.resolveEffective(config);
7169
+ const applied = this.applied.get(sessionId) ?? null;
7170
+ const tools = this.detectTools();
7171
+ return {
7172
+ effective,
7173
+ repoOverride: settings.tokenEfficiencyRepoOverrides[this.repoKeyOf(config)] ?? null,
7174
+ sessionOverride: config.tokenEfficiency ?? null,
7175
+ rtk: tools.rtk,
7176
+ nodeFound: tools.nodeFound,
7177
+ applied,
7178
+ pendingRestart: applied !== null && JSON.stringify(applied) !== JSON.stringify(effective),
7179
+ repoMap: this.repoMaps.get(config.folder) ?? null,
7180
+ savings: this.savingsFor(config.folder)
7181
+ };
7182
+ }
7183
+ /** Persist the global config and re-materialize every session's repo. */
7184
+ saveGlobal(config) {
7185
+ this.persistence.state.settings.tokenEfficiency = config;
7186
+ this.persistence.scheduleSave();
7187
+ this.reapplyAll();
7188
+ }
7189
+ /** Set/clear the override for a session's repo, then re-materialize that repo. */
7190
+ setRepoOverride(sessionId, override) {
7191
+ const config = this.persistence.state.sessions.find((s) => s.id === sessionId);
7192
+ if (!config) return;
7193
+ const key = this.repoKeyOf(config);
7194
+ const overrides = this.persistence.state.settings.tokenEfficiencyRepoOverrides;
7195
+ const cleaned = defined(override);
7196
+ if (override && Object.keys(cleaned).length > 0) overrides[key] = cleaned;
7197
+ else delete overrides[key];
7198
+ this.persistence.scheduleSave();
7199
+ for (const s of this.persistence.state.sessions) {
7200
+ if (this.repoKeyOf(s) === key && s.terminals.some((t) => t.kind === "claude")) this.apply(s);
7201
+ }
7202
+ }
7203
+ /** Set/clear one session's own override, then re-materialize it. */
7204
+ setSessionOverride(sessionId, override) {
7205
+ const config = this.persistence.state.sessions.find((s) => s.id === sessionId);
7206
+ if (!config) return;
7207
+ const cleaned = defined(override);
7208
+ config.tokenEfficiency = override && Object.keys(cleaned).length > 0 ? cleaned : null;
7209
+ this.persistence.scheduleSave();
7210
+ if (config.terminals.some((t) => t.kind === "claude")) this.apply(config);
7211
+ }
7212
+ /** Regenerate a session's repo map right now. */
7213
+ async refreshRepoMap(sessionId) {
7214
+ const config = this.persistence.state.sessions.find((s) => s.id === sessionId);
7215
+ if (!config || !fs.existsSync(config.folder)) return null;
7216
+ const effective = this.resolveEffective(config);
7217
+ if (!effective.enabled || !effective.codeGraph) return null;
7218
+ return this.ensureRepoMap(config.folder, effective, true);
7219
+ }
7220
+ /** Re-materialize every session that hosts a claude terminal. */
7221
+ reapplyAll() {
7222
+ for (const config of this.persistence.state.sessions) {
7223
+ if (config.terminals.some((t) => t.kind === "claude")) this.apply(config);
7224
+ }
7225
+ }
7226
+ }
5190
7227
  let win = null;
5191
7228
  const getWin = () => win;
5192
7229
  const persistence = new Persistence();
@@ -5253,12 +7290,14 @@ if (!gotLock) {
5253
7290
  (sessionId, events) => getWin()?.webContents.send("fs:events", sessionId, events),
5254
7291
  () => persistence.state.settings.ignoreNames
5255
7292
  );
5256
- const sessions = new SessionManager(persistence, fsService, getWin);
7293
+ const tokenEff = new TokenEfficiencyService(persistence);
7294
+ const sessions = new SessionManager(persistence, fsService, tokenEff, getWin);
5257
7295
  const sentinels = new SentinelService(persistence, getWin);
5258
7296
  const features = new FeatureService(persistence, sessions);
5259
7297
  const autoExpand = new AutoExpandService(persistence, features, getWin);
5260
7298
  const conductor = new ConductorService(persistence, sessions, features, autoExpand, getWin);
5261
7299
  const factory = new FactoryService(getWin);
7300
+ const agentRegistry = new AgentRegistryService(persistence, getWin);
5262
7301
  registerIpc(
5263
7302
  sessions,
5264
7303
  fsService,
@@ -5268,6 +7307,8 @@ if (!gotLock) {
5268
7307
  autoExpand,
5269
7308
  conductor,
5270
7309
  factory,
7310
+ agentRegistry,
7311
+ tokenEff,
5271
7312
  getWin
5272
7313
  );
5273
7314
  createWindow();
@@ -5275,10 +7316,15 @@ if (!gotLock) {
5275
7316
  sessions.startWatchdog();
5276
7317
  sentinels.start();
5277
7318
  autoExpand.start();
7319
+ tokenEff.start();
7320
+ factory.start();
7321
+ conductor.onTurnComplete((messages) => factory.considerConversation(messages));
5278
7322
  electron.app.on("activate", () => {
5279
7323
  if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
5280
7324
  });
5281
7325
  electron.app.on("before-quit", () => {
7326
+ tokenEff.dispose();
7327
+ agentRegistry.dispose();
5282
7328
  factory.dispose();
5283
7329
  conductor.dispose();
5284
7330
  autoExpand.dispose();