claude-maestro 0.1.18 → 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 +928 -56
  2. package/out/preload/index.js +10 -0
  3. package/out/renderer/assets/{index-BTPlkF9b.js → index-1Z03T0zz.js} +2 -2
  4. package/out/renderer/assets/{index-DSDf1IPU.js → index-9AHdXE8U.js} +2 -2
  5. package/out/renderer/assets/{index-DVz1TdHX.js → index-B59uuZRU.js} +4 -4
  6. package/out/renderer/assets/{index-BuCQKc-Z.js → index-Bg4ondS2.js} +2 -2
  7. package/out/renderer/assets/{index-CA5CvYDB.js → index-BkOzhsuz.js} +1 -1
  8. package/out/renderer/assets/{index-3GYmITDo.js → index-C0rsWi9C.js} +2 -2
  9. package/out/renderer/assets/{index-D2J11DOI.js → index-C479DZmL.js} +5 -5
  10. package/out/renderer/assets/{index-H1mXv84m.js → index-CNNAMsV1.js} +2 -2
  11. package/out/renderer/assets/{index-DTKHKVCm.js → index-CTxGDYbk.js} +997 -112
  12. package/out/renderer/assets/{index-C9LaSnRB.js → index-CVWvgy2Y.js} +2 -2
  13. package/out/renderer/assets/{index-CYds92PN.js → index-CWk6CwGd.js} +3 -3
  14. package/out/renderer/assets/{index-DYixubwQ.js → index-CXeHg_Qc.js} +2 -2
  15. package/out/renderer/assets/{index-DLIiZzc_.js → index-CZP8wVw-.js} +2 -2
  16. package/out/renderer/assets/{index-DypnZCas.js → index-CoyUYEik.js} +3 -3
  17. package/out/renderer/assets/{index-CIscupyH.js → index-Cq5xQaOf.js} +5 -5
  18. package/out/renderer/assets/{index-dBdOx5at.js → index-CuHjzw7d.js} +5 -5
  19. package/out/renderer/assets/{index-C27Lfjw4.js → index-D9GPva9-.js} +5 -5
  20. package/out/renderer/assets/{index-C3tXFLVi.js → index-DI2ly48w.js} +2 -2
  21. package/out/renderer/assets/{index-D14Kkf05.js → index-DJwKAmOm.js} +2 -2
  22. package/out/renderer/assets/{index-BW8lIWcc.css → index-Dgaj6c_K.css} +4809 -4514
  23. package/out/renderer/assets/{index-tMx4AmRy.js → index-Dhxn3JIv.js} +2 -2
  24. package/out/renderer/assets/{index-CngVaxBn.js → index-JMVyecfQ.js} +5 -5
  25. package/out/renderer/assets/{index-D48Zxs0r.js → index-LW-gCnC-.js} +2 -2
  26. package/out/renderer/assets/{index-CrDDqySQ.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(
@@ -1035,7 +1307,7 @@ function extractJson(text) {
1035
1307
  return null;
1036
1308
  }
1037
1309
  }
1038
- const TICK_MS$1 = 3e4;
1310
+ const TICK_MS$2 = 3e4;
1039
1311
  const AGENT_TIMEOUT_MS = 5 * 6e4;
1040
1312
  const MAX_RUNS_PER_SESSION$1 = 30;
1041
1313
  const IDEA_COUNT = 4;
@@ -1064,7 +1336,7 @@ class AutoExpandService {
1064
1336
  runs = /* @__PURE__ */ new Map();
1065
1337
  start() {
1066
1338
  if (this.timer) return;
1067
- this.timer = setInterval(() => this.tick(), TICK_MS$1);
1339
+ this.timer = setInterval(() => this.tick(), TICK_MS$2);
1068
1340
  this.tick();
1069
1341
  }
1070
1342
  dispose() {
@@ -1431,6 +1703,14 @@ class ConductorService {
1431
1703
  /** The in-flight planner child, so dispose()/a new turn can cancel it. */
1432
1704
  inFlight = null;
1433
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
+ }
1434
1714
  list() {
1435
1715
  return this.messages;
1436
1716
  }
@@ -1508,6 +1788,12 @@ class ConductorService {
1508
1788
  this.inFlight = null;
1509
1789
  this.busy = false;
1510
1790
  this.persistAndBroadcast();
1791
+ if (!assistantMsg.error) {
1792
+ try {
1793
+ this.turnCompleteCb?.(this.messages);
1794
+ } catch {
1795
+ }
1796
+ }
1511
1797
  }
1512
1798
  }
1513
1799
  /**
@@ -2101,12 +2387,21 @@ function scanAgents() {
2101
2387
  }
2102
2388
  return out.sort((a, b) => a.name.localeCompare(b.name));
2103
2389
  }
2104
- 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
+ };
2105
2399
  class FactoryStore {
2106
2400
  file = path.join(electron.app.getPath("userData"), "factory.json");
2107
2401
  timer = null;
2108
2402
  state = { ...EMPTY };
2109
2403
  runs = [];
2404
+ growth = { ...EMPTY_GROWTH };
2110
2405
  /** Load the saved registry (best-effort; an empty registry on any error). */
2111
2406
  load() {
2112
2407
  try {
@@ -2114,12 +2409,19 @@ class FactoryStore {
2114
2409
  this.state = {
2115
2410
  artifacts: Array.isArray(raw?.artifacts) ? raw.artifacts : [],
2116
2411
  topics: Array.isArray(raw?.topics) ? raw.topics : [],
2117
- 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 : []
2118
2415
  };
2119
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
+ }
2120
2421
  } catch {
2121
2422
  this.state = { ...EMPTY };
2122
2423
  this.runs = [];
2424
+ this.growth = { ...EMPTY_GROWTH };
2123
2425
  }
2124
2426
  return this.state;
2125
2427
  }
@@ -2130,6 +2432,10 @@ class FactoryStore {
2130
2432
  loadRuns() {
2131
2433
  return this.runs;
2132
2434
  }
2435
+ /** Self-growth bookkeeping (call after load()). */
2436
+ loadGrowth() {
2437
+ return this.growth;
2438
+ }
2133
2439
  /** Replace the registry and schedule a save. */
2134
2440
  set(state) {
2135
2441
  this.state = state;
@@ -2140,6 +2446,11 @@ class FactoryStore {
2140
2446
  this.runs = runs;
2141
2447
  this.scheduleSave();
2142
2448
  }
2449
+ /** Replace the self-growth bookkeeping and schedule a save. */
2450
+ setGrowth(growth) {
2451
+ this.growth = growth;
2452
+ this.scheduleSave();
2453
+ }
2143
2454
  scheduleSave() {
2144
2455
  if (this.timer) clearTimeout(this.timer);
2145
2456
  this.timer = setTimeout(() => this.saveNow(), 500);
@@ -2152,7 +2463,11 @@ class FactoryStore {
2152
2463
  try {
2153
2464
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
2154
2465
  const tmp = this.file + ".tmp";
2155
- fs.writeFileSync(tmp, JSON.stringify({ ...this.state, runs: this.runs }, null, 2), "utf8");
2466
+ fs.writeFileSync(
2467
+ tmp,
2468
+ JSON.stringify({ ...this.state, runs: this.runs, growth: this.growth }, null, 2),
2469
+ "utf8"
2470
+ );
2156
2471
  fs.renameSync(tmp, this.file);
2157
2472
  } catch (err) {
2158
2473
  console.error("Failed to persist factory registry:", err);
@@ -2166,6 +2481,22 @@ const MAX_CANDIDATES = 8;
2166
2481
  const MAX_RUNS = 25;
2167
2482
  const MAX_TOPICS = 60;
2168
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
+ }
2169
2500
  const KNOWN_LABELS = {
2170
2501
  claude_ai_Atlassian: "Atlassian (Confluence / Jira)",
2171
2502
  claude_ai_Figma: "Figma",
@@ -2177,16 +2508,40 @@ class FactoryService {
2177
2508
  this.getWin = getWin2;
2178
2509
  this.state = this.store.load();
2179
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
+ }
2180
2517
  }
2181
2518
  store = new FactoryStore();
2182
2519
  state;
2183
2520
  runs = [];
2184
2521
  sources = null;
2185
- /** The in-flight agent child, so dispose()/cancel() can kill it. */
2522
+ /** The in-flight cancellable agent child (scan/author/judge), so dispose()/cancel() can kill it. */
2186
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;
2187
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;
2188
2536
  /** Set by cancel(); the in-flight scan/author reports 'cancelled' instead of 'error'. */
2189
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;
2190
2545
  getState() {
2191
2546
  return this.state;
2192
2547
  }
@@ -2194,13 +2549,451 @@ class FactoryService {
2194
2549
  return this.runs;
2195
2550
  }
2196
2551
  dispose() {
2552
+ if (this.timer) clearInterval(this.timer);
2553
+ this.timer = null;
2554
+ if (this.detectTimer) clearTimeout(this.detectTimer);
2555
+ this.detectTimer = null;
2197
2556
  try {
2198
2557
  this.inFlight?.kill();
2199
2558
  } catch {
2200
2559
  }
2560
+ try {
2561
+ this.discoverChild?.kill();
2562
+ } catch {
2563
+ }
2201
2564
  this.inFlight = null;
2565
+ this.discoverChild = null;
2202
2566
  this.store.saveNow();
2203
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
+ }
2204
2997
  /** Cancel the in-flight scan/author agent, if any (the run reports 'cancelled'). */
2205
2998
  cancel() {
2206
2999
  if (!this.inFlight) return;
@@ -2221,24 +3014,48 @@ class FactoryService {
2221
3014
  * connectors are not in ~/.claude.json, so a no-tool headless agent reports
2222
3015
  * what it can see; we merge that with the user-scope servers from
2223
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).
2224
3023
  */
2225
3024
  async listSources(refresh = false) {
2226
3025
  if (this.sources && !refresh) return this.sources;
2227
- const discovered = await this.discoverSources().catch(() => []);
2228
- const byKey = /* @__PURE__ */ new Map();
2229
- for (const s of discovered) byKey.set(s.server, s);
2230
- for (const m of readUserMcpServers()) {
2231
- if (!byKey.has(m.name)) {
2232
- byKey.set(m.name, {
2233
- server: m.name,
2234
- label: KNOWN_LABELS[m.name] ?? m.name,
2235
- toolPrefix: `mcp__${m.name}`,
2236
- readTools: []
2237
- });
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;
2238
3056
  }
2239
- }
2240
- this.sources = [...byKey.values()].sort((a, b) => a.label.localeCompare(b.label));
2241
- return this.sources;
3057
+ })();
3058
+ return this.discovering;
2242
3059
  }
2243
3060
  async discoverSources() {
2244
3061
  const prompt = [
@@ -2265,8 +3082,8 @@ class FactoryService {
2265
3082
  prompt,
2266
3083
  allowedTools: ["Read"],
2267
3084
  timeoutMs: DISCOVER_TIMEOUT_MS,
2268
- onSpawn: (child) => this.inFlight = child
2269
- }).finally(() => this.inFlight = null);
3085
+ onSpawn: (child) => this.discoverChild = child
3086
+ }).finally(() => this.discoverChild = null);
2270
3087
  const parsed = extractJson(out);
2271
3088
  const list = Array.isArray(parsed?.servers) ? parsed.servers : [];
2272
3089
  const sources = [];
@@ -2286,30 +3103,37 @@ class FactoryService {
2286
3103
  return sources;
2287
3104
  }
2288
3105
  // ---------- scan (phase 1) ----------
2289
- /** 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
+ */
2290
3114
  async scan(serverKey, guidance) {
2291
- if (this.busy) return;
2292
- const sources = await this.listSources();
2293
- const source = sources.find((s) => s.server === serverKey);
2294
- if (!source) throw new Error(`Unknown source: ${serverKey}`);
2295
- this.busy = true;
3115
+ if (this.busy) return null;
3116
+ this.setBusy(true);
2296
3117
  this.cancelRequested = false;
2297
- const run = {
2298
- id: crypto.randomUUID(),
2299
- source: source.server,
2300
- sourceLabel: source.label,
2301
- guidance: guidance.trim(),
2302
- startedAt: Date.now(),
2303
- finishedAt: null,
2304
- status: "running",
2305
- phase: "discovering",
2306
- candidates: [],
2307
- summary: ""
2308
- };
2309
- this.pushRun(run);
3118
+ let run = null;
2310
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);
2311
3136
  const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
2312
- run.phase = "discovering";
2313
3137
  this.broadcastRuns();
2314
3138
  const out = await runHeadlessClaude({
2315
3139
  cwd: process.cwd(),
@@ -2325,15 +3149,24 @@ class FactoryService {
2325
3149
  run.status = "done";
2326
3150
  this.absorbTopics(parsed.newTopics, source.server);
2327
3151
  } catch (err) {
2328
- run.status = this.cancelRequested ? "cancelled" : "error";
2329
- run.phase = "done";
2330
- run.summary = this.cancelRequested ? "Cancelled." : 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
+ }
2331
3161
  } finally {
2332
- run.finishedAt = Date.now();
2333
- this.inFlight = null;
2334
- this.busy = false;
2335
- this.broadcastRuns();
3162
+ if (run) {
3163
+ run.finishedAt = Date.now();
3164
+ this.inFlight = null;
3165
+ this.setBusy(false);
3166
+ this.broadcastRuns();
3167
+ }
2336
3168
  }
3169
+ return run ? run.id : null;
2337
3170
  }
2338
3171
  scanPrompt(source, guidance) {
2339
3172
  const existing = [
@@ -2432,13 +3265,13 @@ Lessons learned (respect these):
2432
3265
  const candidate = run?.candidates.find((c) => c.id === candidateId);
2433
3266
  if (!run || !candidate || candidate.status !== "proposed" && candidate.status !== "error") return;
2434
3267
  if (this.busy) return;
2435
- this.busy = true;
3268
+ this.setBusy(true);
2436
3269
  this.cancelRequested = false;
2437
3270
  candidate.status = "authoring";
2438
3271
  candidate.result = void 0;
2439
3272
  this.broadcastRuns();
2440
3273
  try {
2441
- 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: [] };
2442
3275
  const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
2443
3276
  const slug = slugify(candidate.name);
2444
3277
  const out = await runHeadlessClaude({
@@ -2474,7 +3307,7 @@ Lessons learned (respect these):
2474
3307
  }
2475
3308
  } finally {
2476
3309
  this.inFlight = null;
2477
- this.busy = false;
3310
+ this.setBusy(false);
2478
3311
  this.broadcastRuns();
2479
3312
  this.broadcastState();
2480
3313
  }
@@ -2722,6 +3555,28 @@ Lessons learned (respect these):
2722
3555
  broadcastState() {
2723
3556
  this.getWin()?.webContents.send("factory:changed", this.state);
2724
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;
3579
+ }
2725
3580
  broadcastRuns() {
2726
3581
  this.store.setRuns(this.runs);
2727
3582
  this.getWin()?.webContents.send("factory:runs", this.runs);
@@ -3401,7 +4256,7 @@ function tokenize(template) {
3401
4256
  while (m = re.exec(template)) tokens.push(m[1] ?? m[2]);
3402
4257
  return tokens;
3403
4258
  }
3404
- function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, tokenEff, getWin2) {
4259
+ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, getWin2) {
3405
4260
  const rootOf = (id) => {
3406
4261
  const config = sessions.getConfig(id);
3407
4262
  if (!config) throw new Error(`Unknown session: ${id}`);
@@ -3580,6 +4435,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3580
4435
  electron.ipcMain.handle("factory:listSources", (_e, refresh) => factory.listSources(refresh));
3581
4436
  electron.ipcMain.handle("factory:state", () => factory.getState());
3582
4437
  electron.ipcMain.handle("factory:runs", () => factory.listRuns());
4438
+ electron.ipcMain.handle("factory:isBusy", () => factory.isBusy());
3583
4439
  electron.ipcMain.handle(
3584
4440
  "factory:scan",
3585
4441
  (_e, serverKey, guidance) => factory.scan(serverKey, guidance)
@@ -3608,6 +4464,15 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3608
4464
  electron.ipcMain.handle("factory:dismissTopic", (_e, id) => factory.dismissTopic(id));
3609
4465
  electron.ipcMain.handle("factory:addLesson", (_e, text) => factory.addLesson(text));
3610
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));
3611
4476
  electron.ipcMain.handle("actions:list", () => sessions.actions);
3612
4477
  electron.ipcMain.handle("actions:save", (_e, actions) => sessions.saveActions(actions));
3613
4478
  electron.ipcMain.handle(
@@ -3763,6 +4628,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3763
4628
  Object.assign(persistence2.state.settings, patch);
3764
4629
  persistence2.scheduleSave();
3765
4630
  getWin2()?.webContents.send("session:changed");
4631
+ if (patch.agentRegistryPath !== void 0) agents.refresh();
3766
4632
  });
3767
4633
  }
3768
4634
  const DEFAULT_TOKEN_EFFICIENCY = {
@@ -3789,7 +4655,8 @@ const DEFAULT_SETTINGS = {
3789
4655
  watchdogStallMinutes: 10,
3790
4656
  watchdogUnansweredMinutes: 5,
3791
4657
  tokenEfficiency: DEFAULT_TOKEN_EFFICIENCY,
3792
- tokenEfficiencyRepoOverrides: {}
4658
+ tokenEfficiencyRepoOverrides: {},
4659
+ agentRegistryPath: "C:\\repos\\agent-factory\\registry\\registry.json"
3793
4660
  };
3794
4661
  const DEFAULT_CATEGORIES = [
3795
4662
  {
@@ -6430,6 +7297,7 @@ if (!gotLock) {
6430
7297
  const autoExpand = new AutoExpandService(persistence, features, getWin);
6431
7298
  const conductor = new ConductorService(persistence, sessions, features, autoExpand, getWin);
6432
7299
  const factory = new FactoryService(getWin);
7300
+ const agentRegistry = new AgentRegistryService(persistence, getWin);
6433
7301
  registerIpc(
6434
7302
  sessions,
6435
7303
  fsService,
@@ -6439,6 +7307,7 @@ if (!gotLock) {
6439
7307
  autoExpand,
6440
7308
  conductor,
6441
7309
  factory,
7310
+ agentRegistry,
6442
7311
  tokenEff,
6443
7312
  getWin
6444
7313
  );
@@ -6448,11 +7317,14 @@ if (!gotLock) {
6448
7317
  sentinels.start();
6449
7318
  autoExpand.start();
6450
7319
  tokenEff.start();
7320
+ factory.start();
7321
+ conductor.onTurnComplete((messages) => factory.considerConversation(messages));
6451
7322
  electron.app.on("activate", () => {
6452
7323
  if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
6453
7324
  });
6454
7325
  electron.app.on("before-quit", () => {
6455
7326
  tokenEff.dispose();
7327
+ agentRegistry.dispose();
6456
7328
  factory.dispose();
6457
7329
  conductor.dispose();
6458
7330
  autoExpand.dispose();