claude-maestro 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/main/index.js +1333 -98
- package/out/preload/index.js +13 -0
- package/out/renderer/assets/{index-H1mXv84m.js → index--LsdCcT2.js} +2 -2
- package/out/renderer/assets/{index-tMx4AmRy.js → index-2Q5NJLLa.js} +2 -2
- package/out/renderer/assets/{index-BW8lIWcc.css → index-B4pxYlCv.css} +5249 -4514
- package/out/renderer/assets/{index-DVz1TdHX.js → index-B9wQ40iJ.js} +4 -4
- package/out/renderer/assets/{index-C3tXFLVi.js → index-BD5wVgZG.js} +2 -2
- package/out/renderer/assets/{index-CYds92PN.js → index-BKVqTAbd.js} +3 -3
- package/out/renderer/assets/{index-CngVaxBn.js → index-BSnEdUx8.js} +5 -5
- package/out/renderer/assets/{index-D2J11DOI.js → index-BUr9qTcP.js} +5 -5
- package/out/renderer/assets/{index-3GYmITDo.js → index-BX4eMUiW.js} +2 -2
- package/out/renderer/assets/{index-dBdOx5at.js → index-BYrBOYyo.js} +5 -5
- package/out/renderer/assets/{index-C27Lfjw4.js → index-BdkQGOfF.js} +5 -5
- package/out/renderer/assets/{index-DTKHKVCm.js → index-BeXyvqqV.js} +2993 -1543
- package/out/renderer/assets/{index-DSDf1IPU.js → index-C2xG1-2F.js} +2 -2
- package/out/renderer/assets/{index-C9LaSnRB.js → index-CALj_g-h.js} +2 -2
- package/out/renderer/assets/{index-BuCQKc-Z.js → index-CqEbh7gN.js} +2 -2
- package/out/renderer/assets/{index-BTPlkF9b.js → index-CwrdXZxY.js} +2 -2
- package/out/renderer/assets/{index-CrDDqySQ.js → index-DFnn9t0U.js} +5 -5
- package/out/renderer/assets/{index-DLIiZzc_.js → index-DMLpIsZn.js} +2 -2
- package/out/renderer/assets/{index-DypnZCas.js → index-Dpj9EP42.js} +3 -3
- package/out/renderer/assets/{index-CIscupyH.js → index-DwCelZNB.js} +5 -5
- package/out/renderer/assets/{index-CA5CvYDB.js → index-K7jPnu8A.js} +1 -1
- package/out/renderer/assets/{index-D48Zxs0r.js → index-KjJOaoK3.js} +2 -2
- package/out/renderer/assets/{index-D14Kkf05.js → index-NXP0WfIH.js} +2 -2
- package/out/renderer/assets/{index-DYixubwQ.js → index-RU5VUPJx.js} +2 -2
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
package/out/main/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const electron = require("electron");
|
|
3
|
+
const events = require("events");
|
|
3
4
|
const path = require("path");
|
|
4
|
-
const crypto = require("crypto");
|
|
5
5
|
const fs = require("fs");
|
|
6
|
-
const child_process = require("child_process");
|
|
7
6
|
const os = require("os");
|
|
7
|
+
const crypto = require("crypto");
|
|
8
|
+
const child_process = require("child_process");
|
|
8
9
|
const pty = require("node-pty");
|
|
9
10
|
const chokidar = require("chokidar");
|
|
10
11
|
const promises = require("fs/promises");
|
|
@@ -25,6 +26,278 @@ function _interopNamespaceDefault(e) {
|
|
|
25
26
|
return Object.freeze(n);
|
|
26
27
|
}
|
|
27
28
|
const pty__namespace = /* @__PURE__ */ _interopNamespaceDefault(pty);
|
|
29
|
+
const USER_AGENTS_DIR = path.join(os.homedir(), ".claude", "agents");
|
|
30
|
+
const WATCH_DEBOUNCE_MS = 400;
|
|
31
|
+
class AgentRegistryService {
|
|
32
|
+
constructor(persistence2, getWin2) {
|
|
33
|
+
this.persistence = persistence2;
|
|
34
|
+
this.getWin = getWin2;
|
|
35
|
+
}
|
|
36
|
+
watchers = /* @__PURE__ */ new Map();
|
|
37
|
+
debounce = null;
|
|
38
|
+
/** Paths the renderer may read/reveal — everything the last snapshot listed. */
|
|
39
|
+
knownPaths = /* @__PURE__ */ new Set();
|
|
40
|
+
disposed = false;
|
|
41
|
+
dispose() {
|
|
42
|
+
this.disposed = true;
|
|
43
|
+
if (this.debounce) clearTimeout(this.debounce);
|
|
44
|
+
for (const w of this.watchers.values()) w.close();
|
|
45
|
+
this.watchers.clear();
|
|
46
|
+
}
|
|
47
|
+
registryPath() {
|
|
48
|
+
return this.persistence.state.settings.agentRegistryPath.trim();
|
|
49
|
+
}
|
|
50
|
+
/** Unique session repo folders (project-local agents may live in each). */
|
|
51
|
+
projectDirs() {
|
|
52
|
+
return [...new Set(this.persistence.state.sessions.map((s) => s.folder).filter(Boolean))];
|
|
53
|
+
}
|
|
54
|
+
/** Build a fresh snapshot and re-arm the watchers for the dirs it covers. */
|
|
55
|
+
snapshot() {
|
|
56
|
+
const registryPath = this.registryPath();
|
|
57
|
+
const registry = readRegistry(registryPath);
|
|
58
|
+
const agents = scanAgentsDir(USER_AGENTS_DIR, "user", null);
|
|
59
|
+
for (const dir of this.projectDirs()) {
|
|
60
|
+
agents.push(...scanAgentsDir(path.join(dir, ".claude", "agents"), "project", dir));
|
|
61
|
+
}
|
|
62
|
+
agents.sort((a, b) => a.name.localeCompare(b.name));
|
|
63
|
+
const byName = /* @__PURE__ */ new Map();
|
|
64
|
+
for (const e of registry.entries) byName.set(e.name.toLowerCase(), e);
|
|
65
|
+
const matched = /* @__PURE__ */ new Set();
|
|
66
|
+
for (const agent of agents) {
|
|
67
|
+
const entry = byName.get(agent.name.toLowerCase());
|
|
68
|
+
if (entry) {
|
|
69
|
+
agent.registry = entry;
|
|
70
|
+
matched.add(entry);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const missing = registry.entries.filter((e) => e.fileMissing && !matched.has(e));
|
|
74
|
+
const registryDir = path.dirname(registryPath);
|
|
75
|
+
const snapshot = {
|
|
76
|
+
agents,
|
|
77
|
+
missing,
|
|
78
|
+
registryPath,
|
|
79
|
+
registryError: registry.error,
|
|
80
|
+
registryVersion: registry.version,
|
|
81
|
+
registryUpdated: registry.updated,
|
|
82
|
+
factoryRunning: fs.existsSync(path.join(registryDir, ".factory.lock"))
|
|
83
|
+
};
|
|
84
|
+
this.knownPaths = /* @__PURE__ */ new Set();
|
|
85
|
+
for (const a of agents) this.knownPaths.add(a.filePath);
|
|
86
|
+
for (const e of registry.entries) if (e.filePath) this.knownPaths.add(e.filePath);
|
|
87
|
+
this.armWatchers(registryDir);
|
|
88
|
+
return snapshot;
|
|
89
|
+
}
|
|
90
|
+
/** Re-snapshot now and push it to the renderer (manual refresh / settings change). */
|
|
91
|
+
refresh() {
|
|
92
|
+
const snapshot = this.snapshot();
|
|
93
|
+
this.getWin()?.webContents.send("agents:changed", snapshot);
|
|
94
|
+
return snapshot;
|
|
95
|
+
}
|
|
96
|
+
/** Read an agent file the last snapshot listed (null when unknown/unreadable). */
|
|
97
|
+
readAgentFile(filePath) {
|
|
98
|
+
if (this.knownPaths.size === 0) this.snapshot();
|
|
99
|
+
if (!this.knownPaths.has(filePath)) return null;
|
|
100
|
+
try {
|
|
101
|
+
return fs.readFileSync(filePath, "utf8");
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Reveal an agent file from the last snapshot in the OS file manager. */
|
|
107
|
+
revealAgentFile(filePath) {
|
|
108
|
+
if (this.knownPaths.has(filePath) && fs.existsSync(filePath)) electron.shell.showItemInFolder(filePath);
|
|
109
|
+
}
|
|
110
|
+
// ---------- file watching ----------
|
|
111
|
+
/**
|
|
112
|
+
* Watch the user agents dir, every project agents dir and the registry dir
|
|
113
|
+
* (the latter covers registry.json writes AND .factory.lock create/delete).
|
|
114
|
+
* Idempotent: only the delta of dirs is (un)watched.
|
|
115
|
+
*/
|
|
116
|
+
armWatchers(registryDir) {
|
|
117
|
+
if (this.disposed) return;
|
|
118
|
+
const wanted = /* @__PURE__ */ new Set();
|
|
119
|
+
if (fs.existsSync(USER_AGENTS_DIR)) wanted.add(USER_AGENTS_DIR);
|
|
120
|
+
if (fs.existsSync(registryDir)) wanted.add(registryDir);
|
|
121
|
+
for (const dir of this.projectDirs()) {
|
|
122
|
+
const agentsDir = path.join(dir, ".claude", "agents");
|
|
123
|
+
if (fs.existsSync(agentsDir)) wanted.add(agentsDir);
|
|
124
|
+
}
|
|
125
|
+
for (const [dir, watcher] of this.watchers) {
|
|
126
|
+
if (!wanted.has(dir)) {
|
|
127
|
+
watcher.close();
|
|
128
|
+
this.watchers.delete(dir);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const dir of wanted) {
|
|
132
|
+
if (this.watchers.has(dir)) continue;
|
|
133
|
+
try {
|
|
134
|
+
const watcher = fs.watch(dir, () => this.scheduleRefresh());
|
|
135
|
+
watcher.on("error", () => {
|
|
136
|
+
watcher.close();
|
|
137
|
+
this.watchers.delete(dir);
|
|
138
|
+
});
|
|
139
|
+
this.watchers.set(dir, watcher);
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
scheduleRefresh() {
|
|
145
|
+
if (this.disposed) return;
|
|
146
|
+
if (this.debounce) clearTimeout(this.debounce);
|
|
147
|
+
this.debounce = setTimeout(() => {
|
|
148
|
+
this.debounce = null;
|
|
149
|
+
this.refresh();
|
|
150
|
+
}, WATCH_DEBOUNCE_MS);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function parseAgentFrontmatter(md) {
|
|
154
|
+
const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(md);
|
|
155
|
+
if (!m) return {};
|
|
156
|
+
const out = {};
|
|
157
|
+
const lines = m[1].split(/\r?\n/);
|
|
158
|
+
for (let i = 0; i < lines.length; i++) {
|
|
159
|
+
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
|
|
160
|
+
if (!kv) continue;
|
|
161
|
+
const key = kv[1];
|
|
162
|
+
let value = kv[2].trim();
|
|
163
|
+
if (value === "|" || value === ">" || value === "|-" || value === ">-") {
|
|
164
|
+
const block = [];
|
|
165
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
|
|
166
|
+
block.push(lines[++i].trim());
|
|
167
|
+
}
|
|
168
|
+
value = block.join(" ").trim();
|
|
169
|
+
} else if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
170
|
+
value = value.slice(1, -1);
|
|
171
|
+
}
|
|
172
|
+
if (key === "name") out.name = value;
|
|
173
|
+
else if (key === "description") out.description = value;
|
|
174
|
+
else if (key === "model") out.model = value;
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
function scanAgentsDir(dir, scope, projectDir) {
|
|
179
|
+
if (!fs.existsSync(dir)) return [];
|
|
180
|
+
let entries;
|
|
181
|
+
try {
|
|
182
|
+
entries = fs.readdirSync(dir);
|
|
183
|
+
} catch {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
if (!entry.endsWith(".md")) continue;
|
|
189
|
+
const filePath = path.join(dir, entry);
|
|
190
|
+
const base = entry.replace(/\.md$/, "");
|
|
191
|
+
let fm = {};
|
|
192
|
+
try {
|
|
193
|
+
fm = parseAgentFrontmatter(fs.readFileSync(filePath, "utf8"));
|
|
194
|
+
} catch {
|
|
195
|
+
}
|
|
196
|
+
out.push({
|
|
197
|
+
name: fm.name?.trim() || base,
|
|
198
|
+
description: fm.description ?? "",
|
|
199
|
+
model: fm.model?.trim() || null,
|
|
200
|
+
scope,
|
|
201
|
+
projectDir,
|
|
202
|
+
filePath,
|
|
203
|
+
registry: null
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
function readRegistry(registryPath) {
|
|
209
|
+
if (!registryPath) return { entries: [], error: "No registry path configured.", version: null, updated: null };
|
|
210
|
+
if (!fs.existsSync(registryPath)) {
|
|
211
|
+
return { entries: [], error: `Registry not found: ${registryPath}`, version: null, updated: null };
|
|
212
|
+
}
|
|
213
|
+
let parsed;
|
|
214
|
+
try {
|
|
215
|
+
parsed = JSON.parse(fs.readFileSync(registryPath, "utf8"));
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return {
|
|
218
|
+
entries: [],
|
|
219
|
+
error: `Registry unreadable: ${err.message || String(err)}`,
|
|
220
|
+
version: null,
|
|
221
|
+
updated: null
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const root2 = parsed ?? {};
|
|
225
|
+
const meta = root2._meta ?? {};
|
|
226
|
+
const rawAgents = Array.isArray(root2.agents) ? root2.agents : [];
|
|
227
|
+
const entries = [];
|
|
228
|
+
for (const raw of rawAgents) {
|
|
229
|
+
if (!raw || typeof raw !== "object") continue;
|
|
230
|
+
const entry = parseEntry(raw, registryPath);
|
|
231
|
+
if (entry) entries.push(entry);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
entries,
|
|
235
|
+
error: null,
|
|
236
|
+
version: typeof meta.version === "string" ? meta.version : null,
|
|
237
|
+
updated: typeof meta.last_updated === "string" ? meta.last_updated : null
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function parseEntry(r, registryPath) {
|
|
241
|
+
const name = String(r.name ?? "").trim();
|
|
242
|
+
if (!name) return null;
|
|
243
|
+
const filePath = resolveEntryPath(r.file_path, registryPath);
|
|
244
|
+
return {
|
|
245
|
+
name,
|
|
246
|
+
filePath,
|
|
247
|
+
type: optString(r.type),
|
|
248
|
+
status: optString(r.status),
|
|
249
|
+
archetype: optString(r.archetype),
|
|
250
|
+
model: optString(r.model),
|
|
251
|
+
scope: optString(r.scope),
|
|
252
|
+
description: String(r.description ?? "").trim(),
|
|
253
|
+
topics: toStringArray$1(r.topics),
|
|
254
|
+
keywords: toStringArray$1(r.keywords),
|
|
255
|
+
relatedAgents: toStringArray$1(r.related_agents),
|
|
256
|
+
confluencePages: [
|
|
257
|
+
.../* @__PURE__ */ new Set([...toStringArray$1(r.confluence_source), ...toStringArray$1(r.confluence_pages)])
|
|
258
|
+
],
|
|
259
|
+
sourceVerified: r.source_verified === true,
|
|
260
|
+
githubRepos: toGithubRepos(r.github_repos),
|
|
261
|
+
githubVerified: r.github_verified === true,
|
|
262
|
+
knowledgeNotes: toStringArray$1(r.knowledge_note),
|
|
263
|
+
factoryMade: typeof r.factory_made === "boolean" ? r.factory_made : null,
|
|
264
|
+
created: optString(r.created),
|
|
265
|
+
lastUpdated: optString(r.last_updated),
|
|
266
|
+
fileMissing: filePath !== null && !fs.existsSync(filePath)
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function resolveEntryPath(raw, registryPath) {
|
|
270
|
+
const fp = String(raw ?? "").trim();
|
|
271
|
+
if (!fp) return null;
|
|
272
|
+
if (path.isAbsolute(fp)) return fp;
|
|
273
|
+
const registryDir = path.dirname(registryPath);
|
|
274
|
+
const fromRepoRoot = path.resolve(path.dirname(registryDir), fp);
|
|
275
|
+
if (fs.existsSync(fromRepoRoot)) return fromRepoRoot;
|
|
276
|
+
const fromRegistryDir = path.resolve(registryDir, fp);
|
|
277
|
+
return fs.existsSync(fromRegistryDir) ? fromRegistryDir : fromRepoRoot;
|
|
278
|
+
}
|
|
279
|
+
function optString(v) {
|
|
280
|
+
if (typeof v !== "string") return null;
|
|
281
|
+
const s = v.trim();
|
|
282
|
+
return s || null;
|
|
283
|
+
}
|
|
284
|
+
function toStringArray$1(v) {
|
|
285
|
+
if (typeof v === "string") return v.trim() ? [v.trim()] : [];
|
|
286
|
+
if (!Array.isArray(v)) return [];
|
|
287
|
+
return [...new Set(v.map((x) => String(x).trim()).filter(Boolean))];
|
|
288
|
+
}
|
|
289
|
+
function toGithubRepos(v) {
|
|
290
|
+
if (!Array.isArray(v)) return [];
|
|
291
|
+
const out = [];
|
|
292
|
+
for (const raw of v) {
|
|
293
|
+
if (!raw || typeof raw !== "object") continue;
|
|
294
|
+
const r = raw;
|
|
295
|
+
const repo = String(r.repo ?? "").trim();
|
|
296
|
+
if (!repo) continue;
|
|
297
|
+
out.push({ repo, ref: optString(r.ref), paths: toStringArray$1(r.paths) });
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
28
301
|
function git(cwd, args, env) {
|
|
29
302
|
return new Promise((resolve2, reject) => {
|
|
30
303
|
child_process.execFile(
|
|
@@ -737,8 +1010,8 @@ function resolveClaude() {
|
|
|
737
1010
|
if (!IS_WIN$1) {
|
|
738
1011
|
return candidates[0] ? { file: candidates[0], argsPrefix: [] } : null;
|
|
739
1012
|
}
|
|
740
|
-
const exe = candidates.find((
|
|
741
|
-
const cmd = candidates.find((
|
|
1013
|
+
const exe = candidates.find((c2) => c2.toLowerCase().endsWith(".exe"));
|
|
1014
|
+
const cmd = candidates.find((c2) => /\.(cmd|bat)$/i.test(c2));
|
|
742
1015
|
if (exe) return { file: exe, argsPrefix: [] };
|
|
743
1016
|
if (cmd) return { file: process.env.ComSpec ?? "cmd.exe", argsPrefix: ["/c", cmd] };
|
|
744
1017
|
return null;
|
|
@@ -1035,7 +1308,7 @@ function extractJson(text) {
|
|
|
1035
1308
|
return null;
|
|
1036
1309
|
}
|
|
1037
1310
|
}
|
|
1038
|
-
const TICK_MS$
|
|
1311
|
+
const TICK_MS$2 = 3e4;
|
|
1039
1312
|
const AGENT_TIMEOUT_MS = 5 * 6e4;
|
|
1040
1313
|
const MAX_RUNS_PER_SESSION$1 = 30;
|
|
1041
1314
|
const IDEA_COUNT = 4;
|
|
@@ -1051,10 +1324,12 @@ const ALLOWED_TOOLS$2 = [
|
|
|
1051
1324
|
"Bash(git rev-parse:*)"
|
|
1052
1325
|
];
|
|
1053
1326
|
class AutoExpandService {
|
|
1054
|
-
constructor(persistence2, features, getWin2) {
|
|
1327
|
+
constructor(persistence2, features, getWin2, emitGame = () => {
|
|
1328
|
+
}) {
|
|
1055
1329
|
this.persistence = persistence2;
|
|
1056
1330
|
this.features = features;
|
|
1057
1331
|
this.getWin = getWin2;
|
|
1332
|
+
this.emitGame = emitGame;
|
|
1058
1333
|
}
|
|
1059
1334
|
timer = null;
|
|
1060
1335
|
/** Next scheduled run (ms epoch) per session id. */
|
|
@@ -1064,7 +1339,7 @@ class AutoExpandService {
|
|
|
1064
1339
|
runs = /* @__PURE__ */ new Map();
|
|
1065
1340
|
start() {
|
|
1066
1341
|
if (this.timer) return;
|
|
1067
|
-
this.timer = setInterval(() => this.tick(), TICK_MS$
|
|
1342
|
+
this.timer = setInterval(() => this.tick(), TICK_MS$2);
|
|
1068
1343
|
this.tick();
|
|
1069
1344
|
}
|
|
1070
1345
|
dispose() {
|
|
@@ -1251,6 +1526,7 @@ class AutoExpandService {
|
|
|
1251
1526
|
run.finishedAt = Date.now();
|
|
1252
1527
|
run.status = status;
|
|
1253
1528
|
this.broadcast(run.sessionId);
|
|
1529
|
+
if (status === "done") this.emitGame({ type: "autoexpand.done" });
|
|
1254
1530
|
}
|
|
1255
1531
|
broadcast(sessionId) {
|
|
1256
1532
|
this.getWin()?.webContents.send("autoexpand:runs", sessionId, this.listRuns(sessionId));
|
|
@@ -1431,6 +1707,14 @@ class ConductorService {
|
|
|
1431
1707
|
/** The in-flight planner child, so dispose()/a new turn can cancel it. */
|
|
1432
1708
|
inFlight = null;
|
|
1433
1709
|
busy = false;
|
|
1710
|
+
/** Observer fired after each completed (non-error) turn — wired to the Factory's
|
|
1711
|
+
* self-growth detector in index.ts. A post-construction setter avoids a
|
|
1712
|
+
* constructor cycle (conductor is built before the factory). */
|
|
1713
|
+
turnCompleteCb = null;
|
|
1714
|
+
/** Register an observer notified after each successful turn (e.g. the Factory). */
|
|
1715
|
+
onTurnComplete(cb) {
|
|
1716
|
+
this.turnCompleteCb = cb;
|
|
1717
|
+
}
|
|
1434
1718
|
list() {
|
|
1435
1719
|
return this.messages;
|
|
1436
1720
|
}
|
|
@@ -1508,6 +1792,12 @@ class ConductorService {
|
|
|
1508
1792
|
this.inFlight = null;
|
|
1509
1793
|
this.busy = false;
|
|
1510
1794
|
this.persistAndBroadcast();
|
|
1795
|
+
if (!assistantMsg.error) {
|
|
1796
|
+
try {
|
|
1797
|
+
this.turnCompleteCb?.(this.messages);
|
|
1798
|
+
} catch {
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1511
1801
|
}
|
|
1512
1802
|
}
|
|
1513
1803
|
/**
|
|
@@ -1735,9 +2025,9 @@ class ConductorService {
|
|
|
1735
2025
|
const list = focusId ? this.sessions.list().filter((s) => s.config.id === focusId) : this.sessions.list();
|
|
1736
2026
|
const sessions = await Promise.all(
|
|
1737
2027
|
list.map(async (s) => {
|
|
1738
|
-
const
|
|
1739
|
-
const git2 = await this.sessions.getGitStatus(
|
|
1740
|
-
const feats = this.features.list(
|
|
2028
|
+
const c2 = s.config;
|
|
2029
|
+
const git2 = await this.sessions.getGitStatus(c2.id).catch(() => null);
|
|
2030
|
+
const feats = this.features.list(c2.id).map((f) => ({
|
|
1741
2031
|
id: f.id,
|
|
1742
2032
|
title: f.title,
|
|
1743
2033
|
status: f.status,
|
|
@@ -1745,19 +2035,19 @@ class ConductorService {
|
|
|
1745
2035
|
openSpecs: f.specs.filter((sp) => !sp.done).length
|
|
1746
2036
|
}));
|
|
1747
2037
|
const entry = {
|
|
1748
|
-
id:
|
|
1749
|
-
name:
|
|
1750
|
-
folder:
|
|
1751
|
-
isActive:
|
|
2038
|
+
id: c2.id,
|
|
2039
|
+
name: c2.name,
|
|
2040
|
+
folder: c2.folder,
|
|
2041
|
+
isActive: c2.id === activeId,
|
|
1752
2042
|
status: s.status,
|
|
1753
|
-
categoryId:
|
|
2043
|
+
categoryId: c2.categoryId ?? null,
|
|
1754
2044
|
terminals: s.terminals.map((t) => ({ kind: t.config.kind, status: t.status })),
|
|
1755
|
-
worktree:
|
|
1756
|
-
parentSessionId:
|
|
1757
|
-
branch:
|
|
1758
|
-
baseBranch:
|
|
2045
|
+
worktree: c2.worktree ? {
|
|
2046
|
+
parentSessionId: c2.worktree.parentSessionId,
|
|
2047
|
+
branch: c2.worktree.branch,
|
|
2048
|
+
baseBranch: c2.worktree.baseBranch
|
|
1759
2049
|
} : null,
|
|
1760
|
-
autoExpand:
|
|
2050
|
+
autoExpand: c2.autoExpand ? { enabled: c2.autoExpand.enabled, branch: c2.autoExpand.branch } : null,
|
|
1761
2051
|
git: git2 ? {
|
|
1762
2052
|
branch: git2.branch,
|
|
1763
2053
|
ahead: git2.ahead,
|
|
@@ -1766,8 +2056,8 @@ class ConductorService {
|
|
|
1766
2056
|
} : null,
|
|
1767
2057
|
features: feats
|
|
1768
2058
|
};
|
|
1769
|
-
if (
|
|
1770
|
-
const task = await this.sessions.getWorktreeTaskState(
|
|
2059
|
+
if (c2.worktree) {
|
|
2060
|
+
const task = await this.sessions.getWorktreeTaskState(c2.id).catch(() => null);
|
|
1771
2061
|
if (task) {
|
|
1772
2062
|
entry.task = {
|
|
1773
2063
|
dirty: task.dirty,
|
|
@@ -2101,12 +2391,21 @@ function scanAgents() {
|
|
|
2101
2391
|
}
|
|
2102
2392
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
2103
2393
|
}
|
|
2104
|
-
const EMPTY = { artifacts: [], topics: [], lessons: [] };
|
|
2394
|
+
const EMPTY = { artifacts: [], topics: [], lessons: [], suggestions: [] };
|
|
2395
|
+
const EMPTY_GROWTH = {
|
|
2396
|
+
lastScannedAt: {},
|
|
2397
|
+
lastAutoProposeAt: 0,
|
|
2398
|
+
lastDetectAt: 0,
|
|
2399
|
+
turnsSinceDetect: 0,
|
|
2400
|
+
judgeDay: "",
|
|
2401
|
+
judgeCallsToday: 0
|
|
2402
|
+
};
|
|
2105
2403
|
class FactoryStore {
|
|
2106
2404
|
file = path.join(electron.app.getPath("userData"), "factory.json");
|
|
2107
2405
|
timer = null;
|
|
2108
2406
|
state = { ...EMPTY };
|
|
2109
2407
|
runs = [];
|
|
2408
|
+
growth = { ...EMPTY_GROWTH };
|
|
2110
2409
|
/** Load the saved registry (best-effort; an empty registry on any error). */
|
|
2111
2410
|
load() {
|
|
2112
2411
|
try {
|
|
@@ -2114,12 +2413,19 @@ class FactoryStore {
|
|
|
2114
2413
|
this.state = {
|
|
2115
2414
|
artifacts: Array.isArray(raw?.artifacts) ? raw.artifacts : [],
|
|
2116
2415
|
topics: Array.isArray(raw?.topics) ? raw.topics : [],
|
|
2117
|
-
lessons: Array.isArray(raw?.lessons) ? raw.lessons : []
|
|
2416
|
+
lessons: Array.isArray(raw?.lessons) ? raw.lessons : [],
|
|
2417
|
+
// Back-compat: older factory.json files have no `suggestions` key.
|
|
2418
|
+
suggestions: Array.isArray(raw?.suggestions) ? raw.suggestions : []
|
|
2118
2419
|
};
|
|
2119
2420
|
this.runs = Array.isArray(raw?.runs) ? raw.runs : [];
|
|
2421
|
+
this.growth = raw?.growth && typeof raw.growth === "object" ? { ...EMPTY_GROWTH, ...raw.growth } : { ...EMPTY_GROWTH };
|
|
2422
|
+
if (!this.growth.lastScannedAt || typeof this.growth.lastScannedAt !== "object" || Array.isArray(this.growth.lastScannedAt)) {
|
|
2423
|
+
this.growth.lastScannedAt = {};
|
|
2424
|
+
}
|
|
2120
2425
|
} catch {
|
|
2121
2426
|
this.state = { ...EMPTY };
|
|
2122
2427
|
this.runs = [];
|
|
2428
|
+
this.growth = { ...EMPTY_GROWTH };
|
|
2123
2429
|
}
|
|
2124
2430
|
return this.state;
|
|
2125
2431
|
}
|
|
@@ -2130,6 +2436,10 @@ class FactoryStore {
|
|
|
2130
2436
|
loadRuns() {
|
|
2131
2437
|
return this.runs;
|
|
2132
2438
|
}
|
|
2439
|
+
/** Self-growth bookkeeping (call after load()). */
|
|
2440
|
+
loadGrowth() {
|
|
2441
|
+
return this.growth;
|
|
2442
|
+
}
|
|
2133
2443
|
/** Replace the registry and schedule a save. */
|
|
2134
2444
|
set(state) {
|
|
2135
2445
|
this.state = state;
|
|
@@ -2140,6 +2450,11 @@ class FactoryStore {
|
|
|
2140
2450
|
this.runs = runs;
|
|
2141
2451
|
this.scheduleSave();
|
|
2142
2452
|
}
|
|
2453
|
+
/** Replace the self-growth bookkeeping and schedule a save. */
|
|
2454
|
+
setGrowth(growth) {
|
|
2455
|
+
this.growth = growth;
|
|
2456
|
+
this.scheduleSave();
|
|
2457
|
+
}
|
|
2143
2458
|
scheduleSave() {
|
|
2144
2459
|
if (this.timer) clearTimeout(this.timer);
|
|
2145
2460
|
this.timer = setTimeout(() => this.saveNow(), 500);
|
|
@@ -2152,7 +2467,11 @@ class FactoryStore {
|
|
|
2152
2467
|
try {
|
|
2153
2468
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
2154
2469
|
const tmp = this.file + ".tmp";
|
|
2155
|
-
fs.writeFileSync(
|
|
2470
|
+
fs.writeFileSync(
|
|
2471
|
+
tmp,
|
|
2472
|
+
JSON.stringify({ ...this.state, runs: this.runs, growth: this.growth }, null, 2),
|
|
2473
|
+
"utf8"
|
|
2474
|
+
);
|
|
2156
2475
|
fs.renameSync(tmp, this.file);
|
|
2157
2476
|
} catch (err) {
|
|
2158
2477
|
console.error("Failed to persist factory registry:", err);
|
|
@@ -2166,6 +2485,22 @@ const MAX_CANDIDATES = 8;
|
|
|
2166
2485
|
const MAX_RUNS = 25;
|
|
2167
2486
|
const MAX_TOPICS = 60;
|
|
2168
2487
|
const MAX_LESSONS = 40;
|
|
2488
|
+
const TICK_MS$1 = 6e4;
|
|
2489
|
+
const AUTO_PROPOSE_INTERVAL_MS = 6 * 60 * 6e4;
|
|
2490
|
+
const AUTO_PROPOSE_BOOT_GRACE_MS = 10 * 6e4;
|
|
2491
|
+
const DETECT_DEBOUNCE_MS = 4e3;
|
|
2492
|
+
const DETECT_TIMEOUT_MS = 45e3;
|
|
2493
|
+
const MIN_TURNS_SINCE_DETECT = 3;
|
|
2494
|
+
const DETECT_MIN_INTERVAL_MS = 20 * 6e4;
|
|
2495
|
+
const DETECT_CONTEXT_MAX = 6e3;
|
|
2496
|
+
const MIN_CONFIDENCE = 0.6;
|
|
2497
|
+
const MAX_SUGGESTIONS_PER_DETECT = 2;
|
|
2498
|
+
const JUDGE_DAILY_CAP = 12;
|
|
2499
|
+
const MAX_SUGGESTIONS = 60;
|
|
2500
|
+
function localDay$1() {
|
|
2501
|
+
const d = /* @__PURE__ */ new Date();
|
|
2502
|
+
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
|
2503
|
+
}
|
|
2169
2504
|
const KNOWN_LABELS = {
|
|
2170
2505
|
claude_ai_Atlassian: "Atlassian (Confluence / Jira)",
|
|
2171
2506
|
claude_ai_Figma: "Figma",
|
|
@@ -2173,20 +2508,46 @@ const KNOWN_LABELS = {
|
|
|
2173
2508
|
github: "GitHub"
|
|
2174
2509
|
};
|
|
2175
2510
|
class FactoryService {
|
|
2176
|
-
constructor(getWin2) {
|
|
2511
|
+
constructor(getWin2, emitGame = () => {
|
|
2512
|
+
}) {
|
|
2177
2513
|
this.getWin = getWin2;
|
|
2514
|
+
this.emitGame = emitGame;
|
|
2178
2515
|
this.state = this.store.load();
|
|
2179
2516
|
this.runs = restoreRuns(this.store.loadRuns());
|
|
2517
|
+
for (const s of this.state.suggestions) {
|
|
2518
|
+
if (s.status === "creating") {
|
|
2519
|
+
s.status = "open";
|
|
2520
|
+
s.result = void 0;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2180
2523
|
}
|
|
2181
2524
|
store = new FactoryStore();
|
|
2182
2525
|
state;
|
|
2183
2526
|
runs = [];
|
|
2184
2527
|
sources = null;
|
|
2185
|
-
/** The in-flight agent child, so dispose()/cancel() can kill it. */
|
|
2528
|
+
/** The in-flight cancellable agent child (scan/author/judge), so dispose()/cancel() can kill it. */
|
|
2186
2529
|
inFlight = null;
|
|
2530
|
+
/** Source-discovery child — a SEPARATE slot from `inFlight` so discovery (which
|
|
2531
|
+
* can run while `busy` is false) never nulls a live scan/author/judge child. */
|
|
2532
|
+
discoverChild = null;
|
|
2533
|
+
/** In-flight discovery promise; concurrent callers join it instead of spawning
|
|
2534
|
+
* a second discovery agent (the single-headless-child invariant). */
|
|
2535
|
+
discovering = null;
|
|
2187
2536
|
busy = false;
|
|
2537
|
+
/** Resolves when the current heavy op (scan/author/judge) releases the lock —
|
|
2538
|
+
* the public listSources() awaits it before starting a discovery agent so the
|
|
2539
|
+
* two never run concurrently. Null while idle. */
|
|
2540
|
+
busyPromise = null;
|
|
2541
|
+
busyResolve = null;
|
|
2188
2542
|
/** Set by cancel(); the in-flight scan/author reports 'cancelled' instead of 'error'. */
|
|
2189
2543
|
cancelRequested = false;
|
|
2544
|
+
/** Self-growth background timer; null until start(). */
|
|
2545
|
+
timer = null;
|
|
2546
|
+
/** Debounce timer for the conversation detector. */
|
|
2547
|
+
detectTimer = null;
|
|
2548
|
+
/** Claimed synchronously while an auto-propose pass is dispatched (incl. its
|
|
2549
|
+
* pre-scan discovery await), so the 60s timer can't double-dispatch it. */
|
|
2550
|
+
autoProposing = false;
|
|
2190
2551
|
getState() {
|
|
2191
2552
|
return this.state;
|
|
2192
2553
|
}
|
|
@@ -2194,13 +2555,451 @@ class FactoryService {
|
|
|
2194
2555
|
return this.runs;
|
|
2195
2556
|
}
|
|
2196
2557
|
dispose() {
|
|
2558
|
+
if (this.timer) clearInterval(this.timer);
|
|
2559
|
+
this.timer = null;
|
|
2560
|
+
if (this.detectTimer) clearTimeout(this.detectTimer);
|
|
2561
|
+
this.detectTimer = null;
|
|
2197
2562
|
try {
|
|
2198
2563
|
this.inFlight?.kill();
|
|
2199
2564
|
} catch {
|
|
2200
2565
|
}
|
|
2566
|
+
try {
|
|
2567
|
+
this.discoverChild?.kill();
|
|
2568
|
+
} catch {
|
|
2569
|
+
}
|
|
2201
2570
|
this.inFlight = null;
|
|
2571
|
+
this.discoverChild = null;
|
|
2202
2572
|
this.store.saveNow();
|
|
2203
2573
|
}
|
|
2574
|
+
// ---------- self-growth: background timer ----------
|
|
2575
|
+
/** Begin the self-growth loop: an infrequent token-spending auto-propose pass. */
|
|
2576
|
+
start() {
|
|
2577
|
+
if (this.timer) return;
|
|
2578
|
+
const g = this.store.loadGrowth();
|
|
2579
|
+
const earliest = Date.now() - AUTO_PROPOSE_INTERVAL_MS + AUTO_PROPOSE_BOOT_GRACE_MS;
|
|
2580
|
+
if (g.lastAutoProposeAt < earliest) {
|
|
2581
|
+
this.store.setGrowth({ ...g, lastAutoProposeAt: earliest });
|
|
2582
|
+
}
|
|
2583
|
+
this.timer = setInterval(() => this.tick(), TICK_MS$1);
|
|
2584
|
+
}
|
|
2585
|
+
tick() {
|
|
2586
|
+
if (this.busy || this.autoProposing) return;
|
|
2587
|
+
const g = this.store.loadGrowth();
|
|
2588
|
+
if (Date.now() - g.lastAutoProposeAt >= AUTO_PROPOSE_INTERVAL_MS) {
|
|
2589
|
+
void this.autoProposePass().catch(() => {
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Infrequent token-spending pass: scan the connected MCP source that's gone
|
|
2595
|
+
* longest without a scan (round-robin), then convert that scan's proposed
|
|
2596
|
+
* candidates into suggestions (never auto-installed). A dedicated
|
|
2597
|
+
* `autoProposing` guard (claimed synchronously) prevents a second tick from
|
|
2598
|
+
* dispatching while this one is parked in discovery; the budget/rotation slot
|
|
2599
|
+
* is only consumed when a scan actually runs, and we harvest exactly the run
|
|
2600
|
+
* this pass produced (never a stale older one).
|
|
2601
|
+
*/
|
|
2602
|
+
async autoProposePass() {
|
|
2603
|
+
if (this.busy || this.autoProposing) return;
|
|
2604
|
+
this.autoProposing = true;
|
|
2605
|
+
try {
|
|
2606
|
+
const sources = await this.listSources().catch(() => []);
|
|
2607
|
+
if (sources.length === 0) return;
|
|
2608
|
+
const g = this.store.loadGrowth();
|
|
2609
|
+
const next = [...sources].sort(
|
|
2610
|
+
(a, b) => (g.lastScannedAt[a.server] ?? 0) - (g.lastScannedAt[b.server] ?? 0)
|
|
2611
|
+
)[0];
|
|
2612
|
+
const runId = await this.scan(
|
|
2613
|
+
next.server,
|
|
2614
|
+
"Automated background scan: surface only a few high-value, clearly groundable candidates."
|
|
2615
|
+
);
|
|
2616
|
+
if (!runId) return;
|
|
2617
|
+
const g2 = this.store.loadGrowth();
|
|
2618
|
+
this.store.setGrowth({
|
|
2619
|
+
...g2,
|
|
2620
|
+
lastScannedAt: { ...g2.lastScannedAt, [next.server]: Date.now() },
|
|
2621
|
+
lastAutoProposeAt: Date.now()
|
|
2622
|
+
});
|
|
2623
|
+
const run = this.runs.find((r) => r.id === runId);
|
|
2624
|
+
if (run && run.status === "done") this.absorbScanSuggestions(run);
|
|
2625
|
+
} finally {
|
|
2626
|
+
this.autoProposing = false;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
absorbScanSuggestions(run) {
|
|
2630
|
+
let newest = null;
|
|
2631
|
+
let added = 0;
|
|
2632
|
+
for (const c2 of run.candidates) {
|
|
2633
|
+
if (c2.status !== "proposed") continue;
|
|
2634
|
+
if (this.suggestionDuplicate(c2.kind, c2.name, c2.description)) continue;
|
|
2635
|
+
newest = this.enqueueSuggestion({
|
|
2636
|
+
suggestedKind: c2.kind,
|
|
2637
|
+
name: c2.name,
|
|
2638
|
+
title: c2.description,
|
|
2639
|
+
description: c2.description,
|
|
2640
|
+
rationale: c2.rationale,
|
|
2641
|
+
origin: "scan",
|
|
2642
|
+
sourceRef: run.source,
|
|
2643
|
+
sourceLabel: run.sourceLabel,
|
|
2644
|
+
source: run.source,
|
|
2645
|
+
context: run.summary,
|
|
2646
|
+
topics: c2.topics,
|
|
2647
|
+
keywords: c2.keywords,
|
|
2648
|
+
existing: c2.existing,
|
|
2649
|
+
confidence: 0.8
|
|
2650
|
+
});
|
|
2651
|
+
added++;
|
|
2652
|
+
}
|
|
2653
|
+
if (added > 0) {
|
|
2654
|
+
this.persist();
|
|
2655
|
+
if (newest) this.getWin()?.webContents.send("factory:suggestion", newest);
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
// ---------- self-growth: conversation detector ----------
|
|
2659
|
+
/**
|
|
2660
|
+
* Called (fire-and-forget) after each completed Conductor turn. Debounced and
|
|
2661
|
+
* heavily rate-limited; schedules a short headless judge that may queue
|
|
2662
|
+
* skill/agent suggestions. Never blocks or throws into the caller.
|
|
2663
|
+
*/
|
|
2664
|
+
considerConversation(messages) {
|
|
2665
|
+
const g = this.store.loadGrowth();
|
|
2666
|
+
this.store.setGrowth({ ...g, turnsSinceDetect: (g.turnsSinceDetect ?? 0) + 1 });
|
|
2667
|
+
if (this.detectTimer) clearTimeout(this.detectTimer);
|
|
2668
|
+
this.detectTimer = setTimeout(() => {
|
|
2669
|
+
this.detectTimer = null;
|
|
2670
|
+
void this.maybeRunJudge(messages).catch(() => {
|
|
2671
|
+
});
|
|
2672
|
+
}, DETECT_DEBOUNCE_MS);
|
|
2673
|
+
}
|
|
2674
|
+
async maybeRunJudge(messages) {
|
|
2675
|
+
if (this.busy || this.discovering) return;
|
|
2676
|
+
const g = this.store.loadGrowth();
|
|
2677
|
+
const now = Date.now();
|
|
2678
|
+
if (g.turnsSinceDetect < MIN_TURNS_SINCE_DETECT && now - g.lastDetectAt < DETECT_MIN_INTERVAL_MS) {
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
const turnsAtStart = g.turnsSinceDetect;
|
|
2682
|
+
const day = localDay$1();
|
|
2683
|
+
const callsToday = g.judgeDay === day ? g.judgeCallsToday : 0;
|
|
2684
|
+
if (callsToday >= JUDGE_DAILY_CAP) return;
|
|
2685
|
+
const recent = messages.filter((m) => !m.pending && m.text?.trim()).slice(-6);
|
|
2686
|
+
if (recent.length === 0) return;
|
|
2687
|
+
this.setBusy(true);
|
|
2688
|
+
this.cancelRequested = false;
|
|
2689
|
+
try {
|
|
2690
|
+
const convo = recent.map((m) => `${m.role === "user" ? "USER" : "ASSISTANT"}: ${m.text.trim()}`).join("\n\n");
|
|
2691
|
+
const out = await runHeadlessClaude({
|
|
2692
|
+
cwd: process.cwd(),
|
|
2693
|
+
prompt: this.judgePrompt(convo),
|
|
2694
|
+
allowedTools: ["Read"],
|
|
2695
|
+
timeoutMs: DETECT_TIMEOUT_MS,
|
|
2696
|
+
onSpawn: (child) => this.inFlight = child
|
|
2697
|
+
});
|
|
2698
|
+
this.absorbChatSuggestions(
|
|
2699
|
+
this.parseJudge(out),
|
|
2700
|
+
recent[recent.length - 1].id,
|
|
2701
|
+
convo.slice(0, DETECT_CONTEXT_MAX)
|
|
2702
|
+
);
|
|
2703
|
+
} catch {
|
|
2704
|
+
} finally {
|
|
2705
|
+
this.inFlight = null;
|
|
2706
|
+
this.setBusy(false);
|
|
2707
|
+
const g2 = this.store.loadGrowth();
|
|
2708
|
+
const day2 = localDay$1();
|
|
2709
|
+
this.store.setGrowth({
|
|
2710
|
+
...g2,
|
|
2711
|
+
lastDetectAt: Date.now(),
|
|
2712
|
+
turnsSinceDetect: Math.max(0, (g2.turnsSinceDetect ?? 0) - turnsAtStart),
|
|
2713
|
+
judgeDay: day2,
|
|
2714
|
+
judgeCallsToday: (g2.judgeDay === day2 ? g2.judgeCallsToday : 0) + 1
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
judgePrompt(convo) {
|
|
2719
|
+
const existing = this.existingNamesSnapshot();
|
|
2720
|
+
return [
|
|
2721
|
+
"You are the talent scout for an Agent & Skill Factory inside Maestro. You read a recent",
|
|
2722
|
+
"slice of the user's Conductor chat and decide whether it reveals a REUSABLE workflow or",
|
|
2723
|
+
"body of knowledge worth capturing as a Claude Code SKILL (a repeatable procedure the user",
|
|
2724
|
+
"invokes) or SUB-AGENT (a specialist for one bounded domain).",
|
|
2725
|
+
"",
|
|
2726
|
+
"Be conservative: most chats are one-offs and should yield NOTHING. Only suggest when the",
|
|
2727
|
+
"same kind of task would plausibly recur. Never suggest something that overlaps an artifact",
|
|
2728
|
+
"that already exists below — neither a near-duplicate name nor the same purpose.",
|
|
2729
|
+
"",
|
|
2730
|
+
`Artifacts that already exist (do NOT duplicate):
|
|
2731
|
+
${JSON.stringify(existing, null, 2)}`,
|
|
2732
|
+
"",
|
|
2733
|
+
"Recent Conductor conversation (oldest first):",
|
|
2734
|
+
convo,
|
|
2735
|
+
"",
|
|
2736
|
+
"Respond with ONLY one JSON object — no markdown fences, no prose:",
|
|
2737
|
+
'{"suggest": <true|false>,',
|
|
2738
|
+
' "items": [{"kind":"skill|agent",',
|
|
2739
|
+
' "name":"<kebab-case-slug>",',
|
|
2740
|
+
' "title":"<short human title>",',
|
|
2741
|
+
' "description":"<one line: when to use it>",',
|
|
2742
|
+
' "rationale":"<why this conversation shows it would recur>",',
|
|
2743
|
+
' "confidence": <0..1>}]}',
|
|
2744
|
+
'Return {"suggest": false, "items": []} when nothing is worth capturing.'
|
|
2745
|
+
].join("\n");
|
|
2746
|
+
}
|
|
2747
|
+
/** Existing skills/agents (registered + on-disk), for judge prompts and dedupe. */
|
|
2748
|
+
existingNamesSnapshot() {
|
|
2749
|
+
return [
|
|
2750
|
+
...this.state.artifacts.map((a) => ({ kind: a.kind, name: a.name, description: a.description })),
|
|
2751
|
+
...scanSkills().map((s) => ({ kind: "skill", name: s.name, description: s.description ?? "" })),
|
|
2752
|
+
...scanAgents().map((a) => ({ kind: "agent", name: a.name, description: a.description ?? "" }))
|
|
2753
|
+
];
|
|
2754
|
+
}
|
|
2755
|
+
parseJudge(out) {
|
|
2756
|
+
const parsed = extractJson(out);
|
|
2757
|
+
if (!parsed || parsed.suggest === false) return [];
|
|
2758
|
+
const raw = Array.isArray(parsed.items) ? parsed.items : [];
|
|
2759
|
+
const items = [];
|
|
2760
|
+
for (const r of raw) {
|
|
2761
|
+
if (!r || typeof r !== "object") continue;
|
|
2762
|
+
const o = r;
|
|
2763
|
+
const kind = o.kind === "agent" ? "agent" : "skill";
|
|
2764
|
+
const name = slugify(String(o.name ?? ""));
|
|
2765
|
+
const description = String(o.description ?? "").trim();
|
|
2766
|
+
if (!name || !description) continue;
|
|
2767
|
+
let confidence = Number(o.confidence);
|
|
2768
|
+
if (!Number.isFinite(confidence)) confidence = 0;
|
|
2769
|
+
items.push({
|
|
2770
|
+
kind,
|
|
2771
|
+
name,
|
|
2772
|
+
title: String(o.title ?? description).trim() || description,
|
|
2773
|
+
description,
|
|
2774
|
+
rationale: String(o.rationale ?? "").trim(),
|
|
2775
|
+
confidence: Math.max(0, Math.min(1, confidence))
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
return items;
|
|
2779
|
+
}
|
|
2780
|
+
absorbChatSuggestions(items, sourceMsgId, context) {
|
|
2781
|
+
let newest = null;
|
|
2782
|
+
let added = 0;
|
|
2783
|
+
for (const it of items) {
|
|
2784
|
+
if (added >= MAX_SUGGESTIONS_PER_DETECT) break;
|
|
2785
|
+
if (it.confidence < MIN_CONFIDENCE) continue;
|
|
2786
|
+
if (this.suggestionDuplicate(it.kind, it.name, it.title)) continue;
|
|
2787
|
+
newest = this.enqueueSuggestion({
|
|
2788
|
+
suggestedKind: it.kind,
|
|
2789
|
+
name: it.name,
|
|
2790
|
+
title: it.title,
|
|
2791
|
+
description: it.description,
|
|
2792
|
+
rationale: it.rationale,
|
|
2793
|
+
origin: "chat",
|
|
2794
|
+
sourceRef: sourceMsgId,
|
|
2795
|
+
sourceLabel: "Maestro chat",
|
|
2796
|
+
source: null,
|
|
2797
|
+
context,
|
|
2798
|
+
topics: [],
|
|
2799
|
+
keywords: [],
|
|
2800
|
+
existing: null,
|
|
2801
|
+
confidence: it.confidence
|
|
2802
|
+
});
|
|
2803
|
+
added++;
|
|
2804
|
+
}
|
|
2805
|
+
if (added > 0) {
|
|
2806
|
+
this.persist();
|
|
2807
|
+
if (newest) this.getWin()?.webContents.send("factory:suggestion", newest);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
// ---------- self-growth: suggestion queue ----------
|
|
2811
|
+
enqueueSuggestion(input) {
|
|
2812
|
+
const now = Date.now();
|
|
2813
|
+
const s = { id: crypto.randomUUID(), status: "open", createdAt: now, updatedAt: now, ...input };
|
|
2814
|
+
this.state.suggestions.push(s);
|
|
2815
|
+
this.capSuggestions();
|
|
2816
|
+
return s;
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* Keep the queue bounded by pruning ONLY the oldest terminal (created/dismissed)
|
|
2820
|
+
* entries. Actionable suggestions (open/creating/error) are never dropped — if
|
|
2821
|
+
* those alone exceed the cap we let the queue run over rather than silently
|
|
2822
|
+
* losing work the user still has to act on.
|
|
2823
|
+
*/
|
|
2824
|
+
capSuggestions() {
|
|
2825
|
+
if (this.state.suggestions.length <= MAX_SUGGESTIONS) return;
|
|
2826
|
+
const terminalOldestFirst = this.state.suggestions.filter((s) => s.status === "created" || s.status === "dismissed").sort((a, b) => a.updatedAt - b.updatedAt);
|
|
2827
|
+
const drop = /* @__PURE__ */ new Set();
|
|
2828
|
+
for (const s of terminalOldestFirst) {
|
|
2829
|
+
if (this.state.suggestions.length - drop.size <= MAX_SUGGESTIONS) break;
|
|
2830
|
+
drop.add(s.id);
|
|
2831
|
+
}
|
|
2832
|
+
if (drop.size > 0) {
|
|
2833
|
+
this.state.suggestions = this.state.suggestions.filter((x) => !drop.has(x.id));
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
/**
|
|
2837
|
+
* Deterministic, token-free dedupe: is this idea already installed/registered,
|
|
2838
|
+
* or already in the open queue, or semantically the same as one of those?
|
|
2839
|
+
*/
|
|
2840
|
+
suggestionDuplicate(kind, slug, title) {
|
|
2841
|
+
const key = `${kind}:${slug}`;
|
|
2842
|
+
const installed = /* @__PURE__ */ new Set([
|
|
2843
|
+
...this.state.artifacts.map((a) => `${a.kind}:${a.name}`),
|
|
2844
|
+
...listInstalled().map((i) => `${i.kind}:${i.name}`)
|
|
2845
|
+
]);
|
|
2846
|
+
if (installed.has(key)) return "artifact";
|
|
2847
|
+
const words = (t2) => new Set(t2.toLowerCase().replace(/[^a-z0-9 ]+/g, " ").split(/\s+/).filter(Boolean));
|
|
2848
|
+
const jaccard = (a, b) => {
|
|
2849
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
2850
|
+
let inter = 0;
|
|
2851
|
+
for (const w of a) if (b.has(w)) inter++;
|
|
2852
|
+
return inter / (a.size + b.size - inter);
|
|
2853
|
+
};
|
|
2854
|
+
const t = words(title);
|
|
2855
|
+
for (const s of this.state.suggestions) {
|
|
2856
|
+
if (s.status !== "open" && s.status !== "creating") continue;
|
|
2857
|
+
if (s.suggestedKind === kind && s.name === slug) return "suggestion";
|
|
2858
|
+
if (jaccard(t, words(s.title)) >= 0.6) return "suggestion";
|
|
2859
|
+
}
|
|
2860
|
+
for (const a of this.state.artifacts) {
|
|
2861
|
+
if (jaccard(t, words(`${a.name} ${a.description}`)) >= 0.6) return "artifact";
|
|
2862
|
+
}
|
|
2863
|
+
return null;
|
|
2864
|
+
}
|
|
2865
|
+
/** Author + write + register the artifact for a suggestion (the only way one is built). */
|
|
2866
|
+
async createFromSuggestion(id, kind) {
|
|
2867
|
+
const s = this.state.suggestions.find((x) => x.id === id);
|
|
2868
|
+
if (!s || s.status !== "open" && s.status !== "error") return;
|
|
2869
|
+
if (this.busy) return;
|
|
2870
|
+
const useKind = kind ?? s.suggestedKind;
|
|
2871
|
+
this.setBusy(true);
|
|
2872
|
+
this.cancelRequested = false;
|
|
2873
|
+
s.status = "creating";
|
|
2874
|
+
s.suggestedKind = useKind;
|
|
2875
|
+
s.result = void 0;
|
|
2876
|
+
s.updatedAt = Date.now();
|
|
2877
|
+
this.persist();
|
|
2878
|
+
try {
|
|
2879
|
+
await this.discovering?.catch(() => {
|
|
2880
|
+
});
|
|
2881
|
+
const slug = slugify(s.name);
|
|
2882
|
+
let authored;
|
|
2883
|
+
if (s.origin === "scan") {
|
|
2884
|
+
const sources = await this.discoverCache().catch(() => []);
|
|
2885
|
+
const source = sources.find((x) => x.server === s.source) ?? { server: s.source ?? "mcp", label: s.sourceLabel, toolPrefix: `mcp__${s.source ?? ""}`, readTools: [] };
|
|
2886
|
+
const candidate = {
|
|
2887
|
+
id: s.id,
|
|
2888
|
+
kind: useKind,
|
|
2889
|
+
name: slug,
|
|
2890
|
+
description: s.description,
|
|
2891
|
+
topics: s.topics,
|
|
2892
|
+
keywords: s.keywords,
|
|
2893
|
+
rationale: s.rationale,
|
|
2894
|
+
existing: s.existing,
|
|
2895
|
+
status: "authoring"
|
|
2896
|
+
};
|
|
2897
|
+
const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
|
|
2898
|
+
const out = await runHeadlessClaude({
|
|
2899
|
+
cwd: process.cwd(),
|
|
2900
|
+
prompt: this.authorPrompt(source, candidate, slug),
|
|
2901
|
+
allowedTools,
|
|
2902
|
+
timeoutMs: AUTHOR_TIMEOUT_MS,
|
|
2903
|
+
onSpawn: (child) => this.inFlight = child
|
|
2904
|
+
});
|
|
2905
|
+
authored = this.parseAuthor(out);
|
|
2906
|
+
} else {
|
|
2907
|
+
const out = await runHeadlessClaude({
|
|
2908
|
+
cwd: process.cwd(),
|
|
2909
|
+
prompt: this.conversationAuthorPrompt(s, useKind, slug),
|
|
2910
|
+
allowedTools: ["Read", "Grep", "Glob"],
|
|
2911
|
+
timeoutMs: AUTHOR_TIMEOUT_MS,
|
|
2912
|
+
onSpawn: (child) => this.inFlight = child
|
|
2913
|
+
});
|
|
2914
|
+
authored = this.parseAuthor(out);
|
|
2915
|
+
}
|
|
2916
|
+
if (!authored) throw new Error("The author agent did not return usable file content.");
|
|
2917
|
+
const filePath = useKind === "skill" ? writeSkill(slug, authored.content) : writeAgent(slug, authored.content);
|
|
2918
|
+
this.registerArtifact({
|
|
2919
|
+
kind: useKind,
|
|
2920
|
+
name: slug,
|
|
2921
|
+
filePath,
|
|
2922
|
+
description: authored.description || s.description,
|
|
2923
|
+
topics: authored.topics.length ? authored.topics : s.topics,
|
|
2924
|
+
keywords: authored.keywords.length ? authored.keywords : s.keywords,
|
|
2925
|
+
source: s.origin === "scan" ? s.source ?? "scan" : "conversation",
|
|
2926
|
+
related: authored.related
|
|
2927
|
+
});
|
|
2928
|
+
const artifact = this.state.artifacts.find((a) => a.kind === useKind && a.name === slug);
|
|
2929
|
+
s.status = "created";
|
|
2930
|
+
s.artifactId = artifact?.id;
|
|
2931
|
+
s.filePath = filePath;
|
|
2932
|
+
s.result = `Wrote ${useKind} to ${filePath}`;
|
|
2933
|
+
} catch (err) {
|
|
2934
|
+
if (this.cancelRequested) {
|
|
2935
|
+
s.status = "open";
|
|
2936
|
+
s.result = void 0;
|
|
2937
|
+
} else {
|
|
2938
|
+
s.status = "error";
|
|
2939
|
+
s.result = err.message || String(err);
|
|
2940
|
+
}
|
|
2941
|
+
} finally {
|
|
2942
|
+
s.updatedAt = Date.now();
|
|
2943
|
+
this.inFlight = null;
|
|
2944
|
+
this.setBusy(false);
|
|
2945
|
+
this.persist();
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
/** Dismiss a suggestion without building it (kept as history; may resurface later). */
|
|
2949
|
+
dismissSuggestion(id) {
|
|
2950
|
+
const s = this.state.suggestions.find((x) => x.id === id);
|
|
2951
|
+
if (!s || s.status !== "open" && s.status !== "error") return;
|
|
2952
|
+
s.status = "dismissed";
|
|
2953
|
+
s.updatedAt = Date.now();
|
|
2954
|
+
this.persist();
|
|
2955
|
+
}
|
|
2956
|
+
conversationAuthorPrompt(s, kind, slug) {
|
|
2957
|
+
const related = this.state.artifacts.map((a) => ({ name: a.name, kind: a.kind, description: a.description }));
|
|
2958
|
+
const lessons = this.state.lessons.map((l) => l.text);
|
|
2959
|
+
const isSkill = kind === "skill";
|
|
2960
|
+
return [
|
|
2961
|
+
`You are authoring a Claude Code ${isSkill ? "SKILL" : "SUB-AGENT"} that captures a reusable`,
|
|
2962
|
+
"workflow the user demonstrated in a Maestro Conductor conversation. You run unattended.",
|
|
2963
|
+
"",
|
|
2964
|
+
`Target artifact:
|
|
2965
|
+
${JSON.stringify({ kind, name: slug, description: s.description, topics: s.topics, rationale: s.rationale }, null, 2)}`,
|
|
2966
|
+
"",
|
|
2967
|
+
"The conversation that motivated this artifact — capture the GENERAL, reusable procedure it",
|
|
2968
|
+
"shows; strip one-off specifics (particular file names, ids, values) and keep the repeatable",
|
|
2969
|
+
"method:",
|
|
2970
|
+
s.context || "(no excerpt was captured; rely on the target description above)",
|
|
2971
|
+
"",
|
|
2972
|
+
"Write the COMPLETE file content as Markdown with a YAML frontmatter block.",
|
|
2973
|
+
isSkill ? [
|
|
2974
|
+
"For a SKILL the file is SKILL.md. Frontmatter MUST be exactly:",
|
|
2975
|
+
"---",
|
|
2976
|
+
`name: ${slug}`,
|
|
2977
|
+
"description: <one line describing WHEN to use this skill>",
|
|
2978
|
+
"---",
|
|
2979
|
+
"Then the body: a focused, step-by-step procedure the agent can follow."
|
|
2980
|
+
].join("\n") : [
|
|
2981
|
+
"For a SUB-AGENT the file is an agent definition. Frontmatter MUST be exactly:",
|
|
2982
|
+
"---",
|
|
2983
|
+
`name: ${slug}`,
|
|
2984
|
+
"description: <one line: when to route to this agent — be specific with trigger terms>",
|
|
2985
|
+
"model: claude-sonnet-4-6",
|
|
2986
|
+
"---",
|
|
2987
|
+
"Then the body: the agent's system prompt — its scope, what it knows, and what it does NOT cover."
|
|
2988
|
+
].join("\n"),
|
|
2989
|
+
"",
|
|
2990
|
+
`Other artifacts that exist (name any genuinely related):
|
|
2991
|
+
${JSON.stringify(related, null, 2)}`,
|
|
2992
|
+
lessons.length ? `
|
|
2993
|
+
Lessons learned (respect these):
|
|
2994
|
+
- ${lessons.join("\n- ")}` : "",
|
|
2995
|
+
"",
|
|
2996
|
+
"Respond with ONLY one JSON object — no markdown fences around the whole object, no prose:",
|
|
2997
|
+
'{"content":"<the FULL file content, frontmatter + body, as a single string>",',
|
|
2998
|
+
' "description":"<final one-line description>",',
|
|
2999
|
+
' "topics":["..."], "keywords":["..."],',
|
|
3000
|
+
' "related":["<names of related existing artifacts>"]}'
|
|
3001
|
+
].filter((l) => l !== "").join("\n");
|
|
3002
|
+
}
|
|
2204
3003
|
/** Cancel the in-flight scan/author agent, if any (the run reports 'cancelled'). */
|
|
2205
3004
|
cancel() {
|
|
2206
3005
|
if (!this.inFlight) return;
|
|
@@ -2221,24 +3020,48 @@ class FactoryService {
|
|
|
2221
3020
|
* connectors are not in ~/.claude.json, so a no-tool headless agent reports
|
|
2222
3021
|
* what it can see; we merge that with the user-scope servers from
|
|
2223
3022
|
* ~/.claude.json. Cached for the app run; `refresh` forces a re-discovery.
|
|
3023
|
+
*
|
|
3024
|
+
* PUBLIC entry point (IPC / renderer refresh / auto-propose pre-scan): if a
|
|
3025
|
+
* heavy op holds the lock, WAIT for it before starting a discovery agent, so
|
|
3026
|
+
* we never run two headless `claude -p` children at once. Lock-holders
|
|
3027
|
+
* (scan/approve/createFromSuggestion) must call discoverCache() instead — they
|
|
3028
|
+
* already hold the lock and would otherwise wait on themselves (deadlock).
|
|
2224
3029
|
*/
|
|
2225
3030
|
async listSources(refresh = false) {
|
|
2226
3031
|
if (this.sources && !refresh) return this.sources;
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
3032
|
+
while (this.busy && this.busyPromise) await this.busyPromise;
|
|
3033
|
+
return this.discoverCache(refresh);
|
|
3034
|
+
}
|
|
3035
|
+
/**
|
|
3036
|
+
* The actual cached/memoized discovery, with NO busy-wait. Safe to call from a
|
|
3037
|
+
* lock-holder because discovery runs before that op spawns its own child, so
|
|
3038
|
+
* only one headless child is ever alive. Concurrent callers join one discovery.
|
|
3039
|
+
*/
|
|
3040
|
+
discoverCache(refresh = false) {
|
|
3041
|
+
if (this.sources && !refresh) return Promise.resolve(this.sources);
|
|
3042
|
+
if (this.discovering) return this.discovering;
|
|
3043
|
+
this.discovering = (async () => {
|
|
3044
|
+
try {
|
|
3045
|
+
const discovered = await this.discoverSources().catch(() => []);
|
|
3046
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3047
|
+
for (const s of discovered) byKey.set(s.server, s);
|
|
3048
|
+
for (const m of readUserMcpServers()) {
|
|
3049
|
+
if (!byKey.has(m.name)) {
|
|
3050
|
+
byKey.set(m.name, {
|
|
3051
|
+
server: m.name,
|
|
3052
|
+
label: KNOWN_LABELS[m.name] ?? m.name,
|
|
3053
|
+
toolPrefix: `mcp__${m.name}`,
|
|
3054
|
+
readTools: []
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
this.sources = [...byKey.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
3059
|
+
return this.sources;
|
|
3060
|
+
} finally {
|
|
3061
|
+
this.discovering = null;
|
|
2238
3062
|
}
|
|
2239
|
-
}
|
|
2240
|
-
this.
|
|
2241
|
-
return this.sources;
|
|
3063
|
+
})();
|
|
3064
|
+
return this.discovering;
|
|
2242
3065
|
}
|
|
2243
3066
|
async discoverSources() {
|
|
2244
3067
|
const prompt = [
|
|
@@ -2265,8 +3088,8 @@ class FactoryService {
|
|
|
2265
3088
|
prompt,
|
|
2266
3089
|
allowedTools: ["Read"],
|
|
2267
3090
|
timeoutMs: DISCOVER_TIMEOUT_MS,
|
|
2268
|
-
onSpawn: (child) => this.
|
|
2269
|
-
}).finally(() => this.
|
|
3091
|
+
onSpawn: (child) => this.discoverChild = child
|
|
3092
|
+
}).finally(() => this.discoverChild = null);
|
|
2270
3093
|
const parsed = extractJson(out);
|
|
2271
3094
|
const list = Array.isArray(parsed?.servers) ? parsed.servers : [];
|
|
2272
3095
|
const sources = [];
|
|
@@ -2286,30 +3109,37 @@ class FactoryService {
|
|
|
2286
3109
|
return sources;
|
|
2287
3110
|
}
|
|
2288
3111
|
// ---------- scan (phase 1) ----------
|
|
2289
|
-
/**
|
|
3112
|
+
/**
|
|
3113
|
+
* Explore a source and propose skill/agent candidates. Claims the single
|
|
3114
|
+
* `busy` lock SYNCHRONOUSLY (before any await) so two headless agents can
|
|
3115
|
+
* never run at once. Returns the created run's id, or null if it bailed on
|
|
3116
|
+
* the busy guard (no run, no tokens spent) — callers (auto-propose) use this
|
|
3117
|
+
* to harvest exactly this run and to only consume budget when a scan ran.
|
|
3118
|
+
* Throws (releasing the lock) only for an unknown source / discovery failure.
|
|
3119
|
+
*/
|
|
2290
3120
|
async scan(serverKey, guidance) {
|
|
2291
|
-
if (this.busy) return;
|
|
2292
|
-
|
|
2293
|
-
const source = sources.find((s) => s.server === serverKey);
|
|
2294
|
-
if (!source) throw new Error(`Unknown source: ${serverKey}`);
|
|
2295
|
-
this.busy = true;
|
|
3121
|
+
if (this.busy) return null;
|
|
3122
|
+
this.setBusy(true);
|
|
2296
3123
|
this.cancelRequested = false;
|
|
2297
|
-
|
|
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);
|
|
3124
|
+
let run = null;
|
|
2310
3125
|
try {
|
|
3126
|
+
const sources = await this.discoverCache();
|
|
3127
|
+
const source = sources.find((s) => s.server === serverKey);
|
|
3128
|
+
if (!source) throw new Error(`Unknown source: ${serverKey}`);
|
|
3129
|
+
run = {
|
|
3130
|
+
id: crypto.randomUUID(),
|
|
3131
|
+
source: source.server,
|
|
3132
|
+
sourceLabel: source.label,
|
|
3133
|
+
guidance: guidance.trim(),
|
|
3134
|
+
startedAt: Date.now(),
|
|
3135
|
+
finishedAt: null,
|
|
3136
|
+
status: "running",
|
|
3137
|
+
phase: "discovering",
|
|
3138
|
+
candidates: [],
|
|
3139
|
+
summary: ""
|
|
3140
|
+
};
|
|
3141
|
+
this.pushRun(run);
|
|
2311
3142
|
const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
|
|
2312
|
-
run.phase = "discovering";
|
|
2313
3143
|
this.broadcastRuns();
|
|
2314
3144
|
const out = await runHeadlessClaude({
|
|
2315
3145
|
cwd: process.cwd(),
|
|
@@ -2325,15 +3155,24 @@ class FactoryService {
|
|
|
2325
3155
|
run.status = "done";
|
|
2326
3156
|
this.absorbTopics(parsed.newTopics, source.server);
|
|
2327
3157
|
} catch (err) {
|
|
2328
|
-
run
|
|
2329
|
-
|
|
2330
|
-
|
|
3158
|
+
if (run) {
|
|
3159
|
+
run.status = this.cancelRequested ? "cancelled" : "error";
|
|
3160
|
+
run.phase = "done";
|
|
3161
|
+
run.summary = this.cancelRequested ? "Cancelled." : err.message || String(err);
|
|
3162
|
+
} else {
|
|
3163
|
+
this.inFlight = null;
|
|
3164
|
+
this.setBusy(false);
|
|
3165
|
+
throw err;
|
|
3166
|
+
}
|
|
2331
3167
|
} finally {
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
3168
|
+
if (run) {
|
|
3169
|
+
run.finishedAt = Date.now();
|
|
3170
|
+
this.inFlight = null;
|
|
3171
|
+
this.setBusy(false);
|
|
3172
|
+
this.broadcastRuns();
|
|
3173
|
+
}
|
|
2336
3174
|
}
|
|
3175
|
+
return run ? run.id : null;
|
|
2337
3176
|
}
|
|
2338
3177
|
scanPrompt(source, guidance) {
|
|
2339
3178
|
const existing = [
|
|
@@ -2429,16 +3268,16 @@ Lessons learned (respect these):
|
|
|
2429
3268
|
/** Approve a candidate: author its file content and write it to ~/.claude. An errored candidate can be retried. */
|
|
2430
3269
|
async approve(runId, candidateId) {
|
|
2431
3270
|
const run = this.runs.find((r) => r.id === runId);
|
|
2432
|
-
const candidate = run?.candidates.find((
|
|
3271
|
+
const candidate = run?.candidates.find((c2) => c2.id === candidateId);
|
|
2433
3272
|
if (!run || !candidate || candidate.status !== "proposed" && candidate.status !== "error") return;
|
|
2434
3273
|
if (this.busy) return;
|
|
2435
|
-
this.
|
|
3274
|
+
this.setBusy(true);
|
|
2436
3275
|
this.cancelRequested = false;
|
|
2437
3276
|
candidate.status = "authoring";
|
|
2438
3277
|
candidate.result = void 0;
|
|
2439
3278
|
this.broadcastRuns();
|
|
2440
3279
|
try {
|
|
2441
|
-
const source = (await this.
|
|
3280
|
+
const source = (await this.discoverCache()).find((s) => s.server === run.source) ?? { server: run.source, label: run.sourceLabel, toolPrefix: `mcp__${run.source}`, readTools: [] };
|
|
2442
3281
|
const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
|
|
2443
3282
|
const slug = slugify(candidate.name);
|
|
2444
3283
|
const out = await runHeadlessClaude({
|
|
@@ -2474,7 +3313,7 @@ Lessons learned (respect these):
|
|
|
2474
3313
|
}
|
|
2475
3314
|
} finally {
|
|
2476
3315
|
this.inFlight = null;
|
|
2477
|
-
this.
|
|
3316
|
+
this.setBusy(false);
|
|
2478
3317
|
this.broadcastRuns();
|
|
2479
3318
|
this.broadcastState();
|
|
2480
3319
|
}
|
|
@@ -2483,14 +3322,14 @@ Lessons learned (respect these):
|
|
|
2483
3322
|
async approveAll(runId) {
|
|
2484
3323
|
const run = this.runs.find((r) => r.id === runId);
|
|
2485
3324
|
if (!run) return;
|
|
2486
|
-
for (const
|
|
2487
|
-
if (
|
|
3325
|
+
for (const c2 of run.candidates) {
|
|
3326
|
+
if (c2.status === "proposed") await this.approve(runId, c2.id);
|
|
2488
3327
|
if (this.cancelRequested) break;
|
|
2489
3328
|
}
|
|
2490
3329
|
}
|
|
2491
3330
|
reject(runId, candidateId) {
|
|
2492
3331
|
const run = this.runs.find((r) => r.id === runId);
|
|
2493
|
-
const candidate = run?.candidates.find((
|
|
3332
|
+
const candidate = run?.candidates.find((c2) => c2.id === candidateId);
|
|
2494
3333
|
if (!candidate || candidate.status !== "proposed") return;
|
|
2495
3334
|
candidate.status = "rejected";
|
|
2496
3335
|
this.broadcastRuns();
|
|
@@ -2590,6 +3429,10 @@ Lessons learned (respect these):
|
|
|
2590
3429
|
createdAt: now,
|
|
2591
3430
|
updatedAt: now
|
|
2592
3431
|
});
|
|
3432
|
+
this.emitGame({
|
|
3433
|
+
type: input.kind === "agent" ? "factory.agent" : "factory.skill",
|
|
3434
|
+
meta: { name: input.name }
|
|
3435
|
+
});
|
|
2593
3436
|
}
|
|
2594
3437
|
for (const a of this.state.artifacts) {
|
|
2595
3438
|
if (related.includes(a.name) && !a.relatedArtifacts.includes(input.name)) {
|
|
@@ -2722,6 +3565,28 @@ Lessons learned (respect these):
|
|
|
2722
3565
|
broadcastState() {
|
|
2723
3566
|
this.getWin()?.webContents.send("factory:changed", this.state);
|
|
2724
3567
|
}
|
|
3568
|
+
/**
|
|
3569
|
+
* Single source of truth for the headless lock. Setting it broadcasts so the
|
|
3570
|
+
* renderer can reflect background work (judge / author / scan) that doesn't
|
|
3571
|
+
* create a visible FactoryRun — preventing buttons that would silently no-op
|
|
3572
|
+
* against the lock from staying enabled.
|
|
3573
|
+
*/
|
|
3574
|
+
setBusy(v) {
|
|
3575
|
+
if (this.busy === v) return;
|
|
3576
|
+
this.busy = v;
|
|
3577
|
+
if (v) {
|
|
3578
|
+
this.busyPromise = new Promise((resolve) => this.busyResolve = resolve);
|
|
3579
|
+
} else {
|
|
3580
|
+
this.busyResolve?.();
|
|
3581
|
+
this.busyResolve = null;
|
|
3582
|
+
this.busyPromise = null;
|
|
3583
|
+
}
|
|
3584
|
+
this.getWin()?.webContents.send("factory:busy", v);
|
|
3585
|
+
}
|
|
3586
|
+
/** Current headless-lock state (for the initial renderer fetch). */
|
|
3587
|
+
isBusy() {
|
|
3588
|
+
return this.busy;
|
|
3589
|
+
}
|
|
2725
3590
|
broadcastRuns() {
|
|
2726
3591
|
this.store.setRuns(this.runs);
|
|
2727
3592
|
this.getWin()?.webContents.send("factory:runs", this.runs);
|
|
@@ -2735,10 +3600,10 @@ function restoreRuns(runs) {
|
|
|
2735
3600
|
run.finishedAt = run.finishedAt ?? Date.now();
|
|
2736
3601
|
run.summary = run.summary || "Interrupted — the app was closed mid-scan.";
|
|
2737
3602
|
}
|
|
2738
|
-
for (const
|
|
2739
|
-
if (
|
|
2740
|
-
|
|
2741
|
-
|
|
3603
|
+
for (const c2 of run.candidates) {
|
|
3604
|
+
if (c2.status === "authoring") {
|
|
3605
|
+
c2.status = "proposed";
|
|
3606
|
+
c2.result = void 0;
|
|
2742
3607
|
}
|
|
2743
3608
|
}
|
|
2744
3609
|
}
|
|
@@ -2771,9 +3636,11 @@ Feature: ${feature.title}
|
|
|
2771
3636
|
Read that spec file in full, then implement every spec it lists. Make your changes in this worktree and commit them as each part works. If any spec is ambiguous, ask before guessing.`;
|
|
2772
3637
|
}
|
|
2773
3638
|
class FeatureService {
|
|
2774
|
-
constructor(persistence2, sessions) {
|
|
3639
|
+
constructor(persistence2, sessions, emitGame = () => {
|
|
3640
|
+
}) {
|
|
2775
3641
|
this.persistence = persistence2;
|
|
2776
3642
|
this.sessions = sessions;
|
|
3643
|
+
this.emitGame = emitGame;
|
|
2777
3644
|
}
|
|
2778
3645
|
get features() {
|
|
2779
3646
|
return this.persistence.state.features;
|
|
@@ -2794,8 +3661,12 @@ class FeatureService {
|
|
|
2794
3661
|
save(feature) {
|
|
2795
3662
|
const list = this.features;
|
|
2796
3663
|
const idx = list.findIndex((f) => f.id === feature.id);
|
|
2797
|
-
if (idx >= 0)
|
|
2798
|
-
|
|
3664
|
+
if (idx >= 0) {
|
|
3665
|
+
list[idx] = feature;
|
|
3666
|
+
} else {
|
|
3667
|
+
list.push(feature);
|
|
3668
|
+
this.emitGame({ type: "feature.save" });
|
|
3669
|
+
}
|
|
2799
3670
|
this.persistence.scheduleSave();
|
|
2800
3671
|
}
|
|
2801
3672
|
delete(id) {
|
|
@@ -2987,6 +3858,310 @@ class FsService {
|
|
|
2987
3858
|
}
|
|
2988
3859
|
}
|
|
2989
3860
|
}
|
|
3861
|
+
const XP_TABLE = {
|
|
3862
|
+
"session.turn": 5,
|
|
3863
|
+
"conductor.turn": 5,
|
|
3864
|
+
"action.run": 8,
|
|
3865
|
+
"checkpoint.create": 10,
|
|
3866
|
+
"sentinel.run": 12,
|
|
3867
|
+
"session.create": 20,
|
|
3868
|
+
"worktree.create": 25,
|
|
3869
|
+
"feature.save": 30,
|
|
3870
|
+
"autoexpand.done": 40,
|
|
3871
|
+
"worktree.pr": 50,
|
|
3872
|
+
"worktree.merge": 60,
|
|
3873
|
+
"factory.skill": 75,
|
|
3874
|
+
"factory.agent": 75,
|
|
3875
|
+
"feature.merge": 80
|
|
3876
|
+
};
|
|
3877
|
+
function xpForEvent(e) {
|
|
3878
|
+
let xp = XP_TABLE[e.type] ?? 0;
|
|
3879
|
+
if (e.type === "worktree.merge") xp += Math.min(e.meta?.commits ?? 0, 10) * 3;
|
|
3880
|
+
return xp;
|
|
3881
|
+
}
|
|
3882
|
+
function xpForLevel(n) {
|
|
3883
|
+
if (n <= 1) return 0;
|
|
3884
|
+
const k = n - 1;
|
|
3885
|
+
return 50 * k * k + 50 * k;
|
|
3886
|
+
}
|
|
3887
|
+
function levelForXp(xp) {
|
|
3888
|
+
if (xp <= 0) return 1;
|
|
3889
|
+
return Math.max(1, Math.floor((-50 + Math.sqrt(2500 + 200 * xp)) / 100) + 1);
|
|
3890
|
+
}
|
|
3891
|
+
function levelInfo(xp) {
|
|
3892
|
+
const level = levelForXp(xp);
|
|
3893
|
+
const floor = xpForLevel(level);
|
|
3894
|
+
const next = xpForLevel(level + 1);
|
|
3895
|
+
return { level, xpIntoLevel: xp - floor, xpForNextLevel: next - floor };
|
|
3896
|
+
}
|
|
3897
|
+
const DEFAULT_GAME_STATE = {
|
|
3898
|
+
xp: 0,
|
|
3899
|
+
streak: { current: 0, longest: 0, lastDay: "" },
|
|
3900
|
+
achievements: {},
|
|
3901
|
+
todaysQuests: [],
|
|
3902
|
+
questDay: "",
|
|
3903
|
+
counters: {},
|
|
3904
|
+
nightTurns: 0,
|
|
3905
|
+
earlyTurns: 0,
|
|
3906
|
+
createdAt: 0
|
|
3907
|
+
};
|
|
3908
|
+
const c = (ctx, t) => ctx.counters[t] ?? 0;
|
|
3909
|
+
const ACHIEVEMENTS = [
|
|
3910
|
+
// sessions
|
|
3911
|
+
{ id: "first-session", title: "Hello, Maestro", desc: "Start your first session.", icon: "🎬", category: "sessions", predicate: (x) => c(x, "session.create") >= 1 },
|
|
3912
|
+
{ id: "ten-sessions", title: "Regular", desc: "Start 10 sessions.", icon: "📁", category: "sessions", predicate: (x) => c(x, "session.create") >= 10 },
|
|
3913
|
+
{ id: "worktree-novice", title: "Branching Out", desc: "Create your first parallel task.", icon: "🌱", category: "sessions", predicate: (x) => c(x, "worktree.create") >= 1 },
|
|
3914
|
+
{ id: "worktree-adept", title: "Multitasker", desc: "Create 10 parallel tasks.", icon: "🌳", category: "sessions", predicate: (x) => c(x, "worktree.create") >= 10 },
|
|
3915
|
+
// merges
|
|
3916
|
+
{ id: "first-merge", title: "Merge One", desc: "Merge your first worktree.", icon: "🔀", category: "merges", predicate: (x) => c(x, "worktree.merge") >= 1 },
|
|
3917
|
+
{ id: "ten-merges", title: "Merge Maestro", desc: "Merge 10 worktrees.", icon: "🧬", category: "merges", predicate: (x) => c(x, "worktree.merge") >= 10 },
|
|
3918
|
+
{ id: "fifty-merges", title: "Merge Machine", desc: "Merge 50 worktrees.", icon: "⚙️", category: "merges", predicate: (x) => c(x, "worktree.merge") >= 50 },
|
|
3919
|
+
{ id: "pr-opener", title: "Pull Request", desc: "Open your first PR.", icon: "📤", category: "merges", predicate: (x) => c(x, "worktree.pr") >= 1 },
|
|
3920
|
+
{ id: "pr-prolific", title: "PR Prolific", desc: "Open 10 PRs.", icon: "🚀", category: "merges", predicate: (x) => c(x, "worktree.pr") >= 10 },
|
|
3921
|
+
{ id: "feature-shipper", title: "Ship It", desc: "Merge a feature you specced.", icon: "📦", category: "merges", predicate: (x) => c(x, "feature.merge") >= 1 },
|
|
3922
|
+
// turns
|
|
3923
|
+
{ id: "first-turn", title: "First Light", desc: "Finish your first Claude turn.", icon: "✶", category: "turns", predicate: (x) => c(x, "session.turn") >= 1 },
|
|
3924
|
+
{ id: "hundred-turns", title: "Century", desc: "Finish 100 turns.", icon: "💯", category: "turns", predicate: (x) => c(x, "session.turn") >= 100 },
|
|
3925
|
+
{ id: "thousand-turns", title: "Marathoner", desc: "Finish 1,000 turns.", icon: "🏃", category: "turns", predicate: (x) => c(x, "session.turn") >= 1e3 },
|
|
3926
|
+
{ id: "conductor-curious", title: "Conductor Curious", desc: "Have your first Conductor turn.", icon: "✦", category: "turns", predicate: (x) => c(x, "conductor.turn") >= 1 },
|
|
3927
|
+
{ id: "conductor-regular", title: "Maestro of Maestro", desc: "Have 50 Conductor turns.", icon: "🎼", category: "turns", predicate: (x) => c(x, "conductor.turn") >= 50 },
|
|
3928
|
+
// factory
|
|
3929
|
+
{ id: "first-skill", title: "Skill Smith", desc: "Create your first skill.", icon: "🛠", category: "factory", predicate: (x) => c(x, "factory.skill") >= 1 },
|
|
3930
|
+
{ id: "first-agent", title: "Agent Architect", desc: "Create your first agent.", icon: "🤖", category: "factory", predicate: (x) => c(x, "factory.agent") >= 1 },
|
|
3931
|
+
{ id: "toolsmith", title: "Toolsmith", desc: "Create 5 skills/agents.", icon: "⚒", category: "factory", predicate: (x) => c(x, "factory.skill") + c(x, "factory.agent") >= 5 },
|
|
3932
|
+
{ id: "master-toolsmith", title: "Master Toolsmith", desc: "Create 20 skills/agents.", icon: "🏭", category: "factory", predicate: (x) => c(x, "factory.skill") + c(x, "factory.agent") >= 20 },
|
|
3933
|
+
// streak
|
|
3934
|
+
{ id: "streak-3", title: "Warmed Up", desc: "Keep a 3-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 3 },
|
|
3935
|
+
{ id: "streak-7", title: "On Fire", desc: "Keep a 7-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 7 },
|
|
3936
|
+
{ id: "streak-30", title: "Unstoppable", desc: "Keep a 30-day streak.", icon: "☄️", category: "streak", predicate: (x) => x.streakLongest >= 30 },
|
|
3937
|
+
// time of day
|
|
3938
|
+
{ id: "night-owl", title: "Night Owl", desc: "Finish a turn between midnight and 5am.", icon: "🦉", category: "time", predicate: (x) => x.nightTurns >= 1 },
|
|
3939
|
+
{ id: "early-bird", title: "Early Bird", desc: "Finish a turn between 5 and 8am.", icon: "🌅", category: "time", predicate: (x) => x.earlyTurns >= 1 },
|
|
3940
|
+
// level
|
|
3941
|
+
{ id: "level-10", title: "Double Digits", desc: "Reach level 10.", icon: "⭐", category: "level", predicate: (x) => x.level >= 10 },
|
|
3942
|
+
{ id: "level-25", title: "Maestro Prime", desc: "Reach level 25.", icon: "🌟", category: "level", predicate: (x) => x.level >= 25 }
|
|
3943
|
+
];
|
|
3944
|
+
const DAILY_QUEST_POOL = [
|
|
3945
|
+
{ id: "q-turns-5", title: "Finish 5 Claude turns", target: 5, reward: 30, events: ["session.turn"] },
|
|
3946
|
+
{ id: "q-merge-1", title: "Merge a worktree", target: 1, reward: 40, events: ["worktree.merge"] },
|
|
3947
|
+
{ id: "q-merge-3", title: "Merge 3 worktrees", target: 3, reward: 100, events: ["worktree.merge"] },
|
|
3948
|
+
{ id: "q-create-1", title: "Start a session", target: 1, reward: 20, events: ["session.create"] },
|
|
3949
|
+
{ id: "q-worktree-2", title: "Spin up 2 parallel tasks", target: 2, reward: 50, events: ["worktree.create"] },
|
|
3950
|
+
{ id: "q-checkpoint", title: "Make a checkpoint", target: 1, reward: 20, events: ["checkpoint.create"] },
|
|
3951
|
+
{ id: "q-conductor-3", title: "Have 3 Conductor turns", target: 3, reward: 30, events: ["conductor.turn"] },
|
|
3952
|
+
{ id: "q-factory-1", title: "Create a skill or agent", target: 1, reward: 75, events: ["factory.skill", "factory.agent"] },
|
|
3953
|
+
{ id: "q-feature-1", title: "Save a feature spec", target: 1, reward: 30, events: ["feature.save"] },
|
|
3954
|
+
{ id: "q-pr-1", title: "Open a pull request", target: 1, reward: 50, events: ["worktree.pr"] }
|
|
3955
|
+
];
|
|
3956
|
+
const questDef = (id) => DAILY_QUEST_POOL.find((q) => q.id === id);
|
|
3957
|
+
function hashStr(s) {
|
|
3958
|
+
let h = 2166136261;
|
|
3959
|
+
for (let i = 0; i < s.length; i++) {
|
|
3960
|
+
h ^= s.charCodeAt(i);
|
|
3961
|
+
h = Math.imul(h, 16777619);
|
|
3962
|
+
}
|
|
3963
|
+
return h >>> 0;
|
|
3964
|
+
}
|
|
3965
|
+
function mulberry32(seed) {
|
|
3966
|
+
let a = seed >>> 0;
|
|
3967
|
+
return () => {
|
|
3968
|
+
a = a + 1831565813 | 0;
|
|
3969
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
3970
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
3971
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3974
|
+
function pickDailyQuests(dayKey2, n = 3) {
|
|
3975
|
+
const rnd = mulberry32(hashStr("quest:" + dayKey2));
|
|
3976
|
+
const pool = [...DAILY_QUEST_POOL];
|
|
3977
|
+
for (let i = pool.length - 1; i > 0; i--) {
|
|
3978
|
+
const j = Math.floor(rnd() * (i + 1));
|
|
3979
|
+
[pool[i], pool[j]] = [pool[j], pool[i]];
|
|
3980
|
+
}
|
|
3981
|
+
return pool.slice(0, Math.min(n, pool.length)).map((q) => ({
|
|
3982
|
+
id: q.id,
|
|
3983
|
+
target: q.target,
|
|
3984
|
+
progress: 0,
|
|
3985
|
+
rewarded: false
|
|
3986
|
+
}));
|
|
3987
|
+
}
|
|
3988
|
+
function dayKey(d) {
|
|
3989
|
+
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
|
3990
|
+
}
|
|
3991
|
+
function prevDayKey(d) {
|
|
3992
|
+
const y = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1);
|
|
3993
|
+
return dayKey(y);
|
|
3994
|
+
}
|
|
3995
|
+
class GamificationStore {
|
|
3996
|
+
file = path.join(electron.app.getPath("userData"), "gamification.json");
|
|
3997
|
+
timer = null;
|
|
3998
|
+
state = structuredClone(DEFAULT_GAME_STATE);
|
|
3999
|
+
/** Load saved progress (best-effort; defaults on any error, back-compat via spread). */
|
|
4000
|
+
load() {
|
|
4001
|
+
try {
|
|
4002
|
+
const raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
4003
|
+
const s = { ...structuredClone(DEFAULT_GAME_STATE), ...raw ?? {} };
|
|
4004
|
+
const num = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : Number.isFinite(Number(v)) ? Number(v) : fallback;
|
|
4005
|
+
if (!s.streak || typeof s.streak !== "object") s.streak = { current: 0, longest: 0, lastDay: "" };
|
|
4006
|
+
s.streak.current = num(s.streak.current, 0);
|
|
4007
|
+
s.streak.longest = num(s.streak.longest, 0);
|
|
4008
|
+
if (typeof s.streak.lastDay !== "string") s.streak.lastDay = "";
|
|
4009
|
+
if (!s.achievements || typeof s.achievements !== "object" || Array.isArray(s.achievements)) {
|
|
4010
|
+
s.achievements = {};
|
|
4011
|
+
}
|
|
4012
|
+
if (!Array.isArray(s.todaysQuests)) s.todaysQuests = [];
|
|
4013
|
+
s.todaysQuests = s.todaysQuests.filter((q) => q && typeof q.id === "string").map((q) => ({ id: q.id, target: num(q.target, 1), progress: num(q.progress, 0), rewarded: !!q.rewarded }));
|
|
4014
|
+
if (!s.counters || typeof s.counters !== "object" || Array.isArray(s.counters)) s.counters = {};
|
|
4015
|
+
for (const k of Object.keys(s.counters)) {
|
|
4016
|
+
s.counters[k] = num(s.counters[k], 0);
|
|
4017
|
+
}
|
|
4018
|
+
s.nightTurns = num(s.nightTurns, 0);
|
|
4019
|
+
s.earlyTurns = num(s.earlyTurns, 0);
|
|
4020
|
+
s.xp = num(s.xp, 0);
|
|
4021
|
+
if (!s.createdAt) s.createdAt = Date.now();
|
|
4022
|
+
this.state = s;
|
|
4023
|
+
} catch {
|
|
4024
|
+
this.state = structuredClone(DEFAULT_GAME_STATE);
|
|
4025
|
+
this.state.createdAt = Date.now();
|
|
4026
|
+
}
|
|
4027
|
+
return this.state;
|
|
4028
|
+
}
|
|
4029
|
+
get() {
|
|
4030
|
+
return this.state;
|
|
4031
|
+
}
|
|
4032
|
+
set(state) {
|
|
4033
|
+
this.state = state;
|
|
4034
|
+
this.scheduleSave();
|
|
4035
|
+
}
|
|
4036
|
+
scheduleSave() {
|
|
4037
|
+
if (this.timer) clearTimeout(this.timer);
|
|
4038
|
+
this.timer = setTimeout(() => this.saveNow(), 500);
|
|
4039
|
+
}
|
|
4040
|
+
saveNow() {
|
|
4041
|
+
if (this.timer) {
|
|
4042
|
+
clearTimeout(this.timer);
|
|
4043
|
+
this.timer = null;
|
|
4044
|
+
}
|
|
4045
|
+
try {
|
|
4046
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
4047
|
+
const tmp = this.file + ".tmp";
|
|
4048
|
+
fs.writeFileSync(tmp, JSON.stringify(this.state, null, 2), "utf8");
|
|
4049
|
+
fs.renameSync(tmp, this.file);
|
|
4050
|
+
} catch (err) {
|
|
4051
|
+
console.error("Failed to persist gamification state:", err);
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
const ACHIEVEMENT_XP = 25;
|
|
4056
|
+
class GamificationService {
|
|
4057
|
+
constructor(getWin2) {
|
|
4058
|
+
this.getWin = getWin2;
|
|
4059
|
+
this.state = this.store.load();
|
|
4060
|
+
}
|
|
4061
|
+
store = new GamificationStore();
|
|
4062
|
+
state;
|
|
4063
|
+
/** GameState + derived level fields (for the initial renderer fetch). */
|
|
4064
|
+
snapshot() {
|
|
4065
|
+
return { ...this.state, ...levelInfo(this.state.xp) };
|
|
4066
|
+
}
|
|
4067
|
+
dispose() {
|
|
4068
|
+
this.store.saveNow();
|
|
4069
|
+
}
|
|
4070
|
+
/**
|
|
4071
|
+
* Apply one event: bump counters, roll the day (streak + quests), add XP,
|
|
4072
|
+
* advance quests, unlock achievements — each guarded so nothing double-counts.
|
|
4073
|
+
* Wrapped so a gamification failure can never break the caller's turn/merge.
|
|
4074
|
+
*/
|
|
4075
|
+
award(e) {
|
|
4076
|
+
try {
|
|
4077
|
+
const s = this.state;
|
|
4078
|
+
const now = /* @__PURE__ */ new Date();
|
|
4079
|
+
const today = dayKey(now);
|
|
4080
|
+
const celebrations = [];
|
|
4081
|
+
if (s.streak.lastDay !== today) {
|
|
4082
|
+
s.streak.current = s.streak.lastDay === prevDayKey(now) ? s.streak.current + 1 : 1;
|
|
4083
|
+
s.streak.longest = Math.max(s.streak.longest, s.streak.current);
|
|
4084
|
+
s.streak.lastDay = today;
|
|
4085
|
+
s.todaysQuests = pickDailyQuests(today);
|
|
4086
|
+
s.questDay = today;
|
|
4087
|
+
if (s.streak.current >= 2) {
|
|
4088
|
+
celebrations.push({
|
|
4089
|
+
kind: "streak",
|
|
4090
|
+
seed: `streak:${today}:${s.streak.current}`,
|
|
4091
|
+
current: s.streak.current
|
|
4092
|
+
});
|
|
4093
|
+
}
|
|
4094
|
+
}
|
|
4095
|
+
s.counters[e.type] = (s.counters[e.type] ?? 0) + 1;
|
|
4096
|
+
if (e.type === "session.turn" || e.type === "conductor.turn") {
|
|
4097
|
+
const hour = e.meta?.hour ?? now.getHours();
|
|
4098
|
+
if (hour >= 0 && hour < 5) s.nightTurns += 1;
|
|
4099
|
+
else if (hour >= 5 && hour < 8) s.earlyTurns += 1;
|
|
4100
|
+
}
|
|
4101
|
+
const beforeLevel = levelForXp(s.xp);
|
|
4102
|
+
s.xp += xpForEvent(e);
|
|
4103
|
+
for (const q of s.todaysQuests) {
|
|
4104
|
+
if (q.rewarded) continue;
|
|
4105
|
+
const def = questDef(q.id);
|
|
4106
|
+
if (!def || !def.events.includes(e.type)) continue;
|
|
4107
|
+
q.progress = Math.min(q.target, q.progress + 1);
|
|
4108
|
+
if (q.progress >= q.target) {
|
|
4109
|
+
q.rewarded = true;
|
|
4110
|
+
s.xp += def.reward;
|
|
4111
|
+
celebrations.push({
|
|
4112
|
+
kind: "quest",
|
|
4113
|
+
seed: `quest:${today}:${q.id}`,
|
|
4114
|
+
id: q.id,
|
|
4115
|
+
title: def.title,
|
|
4116
|
+
xp: def.reward
|
|
4117
|
+
});
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
let unlockedThisPass = true;
|
|
4121
|
+
while (unlockedThisPass) {
|
|
4122
|
+
unlockedThisPass = false;
|
|
4123
|
+
const ctx = {
|
|
4124
|
+
counters: s.counters,
|
|
4125
|
+
nightTurns: s.nightTurns,
|
|
4126
|
+
earlyTurns: s.earlyTurns,
|
|
4127
|
+
streakLongest: s.streak.longest,
|
|
4128
|
+
level: levelForXp(s.xp)
|
|
4129
|
+
};
|
|
4130
|
+
for (const a of ACHIEVEMENTS) {
|
|
4131
|
+
if (s.achievements[a.id]) continue;
|
|
4132
|
+
if (a.predicate(ctx)) {
|
|
4133
|
+
s.achievements[a.id] = { unlockedAt: Date.now() };
|
|
4134
|
+
s.xp += ACHIEVEMENT_XP;
|
|
4135
|
+
unlockedThisPass = true;
|
|
4136
|
+
celebrations.push({
|
|
4137
|
+
kind: "achievement",
|
|
4138
|
+
seed: `ach:${a.id}`,
|
|
4139
|
+
id: a.id,
|
|
4140
|
+
title: a.title,
|
|
4141
|
+
icon: a.icon,
|
|
4142
|
+
xp: ACHIEVEMENT_XP
|
|
4143
|
+
});
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
const afterLevel = levelForXp(s.xp);
|
|
4148
|
+
for (let l = beforeLevel + 1; l <= afterLevel; l++) {
|
|
4149
|
+
celebrations.push({ kind: "level-up", seed: `level:${l}`, level: l });
|
|
4150
|
+
}
|
|
4151
|
+
this.store.set(s);
|
|
4152
|
+
this.broadcast();
|
|
4153
|
+
for (const c2 of celebrations) this.celebrate(c2);
|
|
4154
|
+
} catch (err) {
|
|
4155
|
+
console.error("Gamification award failed (ignored):", err);
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
broadcast() {
|
|
4159
|
+
this.getWin()?.webContents.send("gamification:changed", this.snapshot());
|
|
4160
|
+
}
|
|
4161
|
+
celebrate(c2) {
|
|
4162
|
+
this.getWin()?.webContents.send("gamification:celebrate", c2);
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
2990
4165
|
const IMAGE_EXTS$1 = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
|
|
2991
4166
|
const THUMB_HEIGHT = 64;
|
|
2992
4167
|
const MAX_LISTED = 100;
|
|
@@ -3401,7 +4576,7 @@ function tokenize(template) {
|
|
|
3401
4576
|
while (m = re.exec(template)) tokens.push(m[1] ?? m[2]);
|
|
3402
4577
|
return tokens;
|
|
3403
4578
|
}
|
|
3404
|
-
function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, tokenEff, getWin2) {
|
|
4579
|
+
function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, gamification, getWin2) {
|
|
3405
4580
|
const rootOf = (id) => {
|
|
3406
4581
|
const config = sessions.getConfig(id);
|
|
3407
4582
|
if (!config) throw new Error(`Unknown session: ${id}`);
|
|
@@ -3580,6 +4755,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
|
|
|
3580
4755
|
electron.ipcMain.handle("factory:listSources", (_e, refresh) => factory.listSources(refresh));
|
|
3581
4756
|
electron.ipcMain.handle("factory:state", () => factory.getState());
|
|
3582
4757
|
electron.ipcMain.handle("factory:runs", () => factory.listRuns());
|
|
4758
|
+
electron.ipcMain.handle("factory:isBusy", () => factory.isBusy());
|
|
3583
4759
|
electron.ipcMain.handle(
|
|
3584
4760
|
"factory:scan",
|
|
3585
4761
|
(_e, serverKey, guidance) => factory.scan(serverKey, guidance)
|
|
@@ -3608,6 +4784,16 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
|
|
|
3608
4784
|
electron.ipcMain.handle("factory:dismissTopic", (_e, id) => factory.dismissTopic(id));
|
|
3609
4785
|
electron.ipcMain.handle("factory:addLesson", (_e, text) => factory.addLesson(text));
|
|
3610
4786
|
electron.ipcMain.handle("factory:deleteLesson", (_e, id) => factory.deleteLesson(id));
|
|
4787
|
+
electron.ipcMain.handle(
|
|
4788
|
+
"factory:createFromSuggestion",
|
|
4789
|
+
(_e, id, kind) => factory.createFromSuggestion(id, kind)
|
|
4790
|
+
);
|
|
4791
|
+
electron.ipcMain.handle("factory:dismissSuggestion", (_e, id) => factory.dismissSuggestion(id));
|
|
4792
|
+
electron.ipcMain.handle("gamification:get", () => gamification.snapshot());
|
|
4793
|
+
electron.ipcMain.handle("agents:get", () => agents.snapshot());
|
|
4794
|
+
electron.ipcMain.handle("agents:refresh", () => agents.refresh());
|
|
4795
|
+
electron.ipcMain.handle("agents:read", (_e, filePath) => agents.readAgentFile(filePath));
|
|
4796
|
+
electron.ipcMain.handle("agents:reveal", (_e, filePath) => agents.revealAgentFile(filePath));
|
|
3611
4797
|
electron.ipcMain.handle("actions:list", () => sessions.actions);
|
|
3612
4798
|
electron.ipcMain.handle("actions:save", (_e, actions) => sessions.saveActions(actions));
|
|
3613
4799
|
electron.ipcMain.handle(
|
|
@@ -3763,6 +4949,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
|
|
|
3763
4949
|
Object.assign(persistence2.state.settings, patch);
|
|
3764
4950
|
persistence2.scheduleSave();
|
|
3765
4951
|
getWin2()?.webContents.send("session:changed");
|
|
4952
|
+
if (patch.agentRegistryPath !== void 0) agents.refresh();
|
|
3766
4953
|
});
|
|
3767
4954
|
}
|
|
3768
4955
|
const DEFAULT_TOKEN_EFFICIENCY = {
|
|
@@ -3789,7 +4976,13 @@ const DEFAULT_SETTINGS = {
|
|
|
3789
4976
|
watchdogStallMinutes: 10,
|
|
3790
4977
|
watchdogUnansweredMinutes: 5,
|
|
3791
4978
|
tokenEfficiency: DEFAULT_TOKEN_EFFICIENCY,
|
|
3792
|
-
tokenEfficiencyRepoOverrides: {}
|
|
4979
|
+
tokenEfficiencyRepoOverrides: {},
|
|
4980
|
+
agentRegistryPath: "C:\\repos\\agent-factory\\registry\\registry.json",
|
|
4981
|
+
theme: "dark",
|
|
4982
|
+
accentColor: null,
|
|
4983
|
+
gamificationEnabled: true,
|
|
4984
|
+
gamificationReduceMotion: false,
|
|
4985
|
+
gamificationSound: false
|
|
3793
4986
|
};
|
|
3794
4987
|
const DEFAULT_CATEGORIES = [
|
|
3795
4988
|
{
|
|
@@ -3959,9 +5152,11 @@ function gitHead$1(folder) {
|
|
|
3959
5152
|
});
|
|
3960
5153
|
}
|
|
3961
5154
|
class SentinelService {
|
|
3962
|
-
constructor(persistence2, getWin2) {
|
|
5155
|
+
constructor(persistence2, getWin2, emitGame = () => {
|
|
5156
|
+
}) {
|
|
3963
5157
|
this.persistence = persistence2;
|
|
3964
5158
|
this.getWin = getWin2;
|
|
5159
|
+
this.emitGame = emitGame;
|
|
3965
5160
|
}
|
|
3966
5161
|
timer = null;
|
|
3967
5162
|
/** Last observed HEAD per session id; baseline is set without firing. */
|
|
@@ -4141,6 +5336,7 @@ class SentinelService {
|
|
|
4141
5336
|
run.summary = summary;
|
|
4142
5337
|
run.findings = findings;
|
|
4143
5338
|
this.broadcast(run.sessionId);
|
|
5339
|
+
if (status !== "error") this.emitGame({ type: "sentinel.run" });
|
|
4144
5340
|
}
|
|
4145
5341
|
broadcast(sessionId) {
|
|
4146
5342
|
this.getWin()?.webContents.send("sentinel:runs", sessionId, this.listRuns(sessionId));
|
|
@@ -4420,11 +5616,13 @@ const STATUS_PRIORITY = [
|
|
|
4420
5616
|
"exited"
|
|
4421
5617
|
];
|
|
4422
5618
|
class SessionManager {
|
|
4423
|
-
constructor(persistence2, fs2, tokenEff, getWin2) {
|
|
5619
|
+
constructor(persistence2, fs2, tokenEff, getWin2, emitGame = () => {
|
|
5620
|
+
}) {
|
|
4424
5621
|
this.persistence = persistence2;
|
|
4425
5622
|
this.fs = fs2;
|
|
4426
5623
|
this.tokenEff = tokenEff;
|
|
4427
5624
|
this.getWin = getWin2;
|
|
5625
|
+
this.emitGame = emitGame;
|
|
4428
5626
|
}
|
|
4429
5627
|
/** Keyed by terminal id, across all sessions. */
|
|
4430
5628
|
ptys = /* @__PURE__ */ new Map();
|
|
@@ -4449,6 +5647,8 @@ class SessionManager {
|
|
|
4449
5647
|
watchdog = /* @__PURE__ */ new Map();
|
|
4450
5648
|
/** The watchdog interval; null until startWatchdog() runs. */
|
|
4451
5649
|
watchdogTimer = null;
|
|
5650
|
+
/** Per-terminal last status, used only to award a turn on the working→done edge. */
|
|
5651
|
+
lastStatusForAward = /* @__PURE__ */ new Map();
|
|
4452
5652
|
get state() {
|
|
4453
5653
|
return this.persistence.state;
|
|
4454
5654
|
}
|
|
@@ -4460,12 +5660,12 @@ class SessionManager {
|
|
|
4460
5660
|
}
|
|
4461
5661
|
categoryOf(config) {
|
|
4462
5662
|
if (!config.categoryId) return null;
|
|
4463
|
-
return this.state.categories.find((
|
|
5663
|
+
return this.state.categories.find((c2) => c2.id === config.categoryId) ?? null;
|
|
4464
5664
|
}
|
|
4465
5665
|
/** Every MCP server name owned by any category — our materialization namespace. */
|
|
4466
5666
|
managedServerNames() {
|
|
4467
5667
|
const names = /* @__PURE__ */ new Set();
|
|
4468
|
-
for (const
|
|
5668
|
+
for (const c2 of this.state.categories) for (const s of c2.mcpServers) names.add(s.name);
|
|
4469
5669
|
return [...names];
|
|
4470
5670
|
}
|
|
4471
5671
|
/**
|
|
@@ -4516,6 +5716,7 @@ class SessionManager {
|
|
|
4516
5716
|
for (const terminal of config.terminals) this.spawnTerminal(config, terminal, "fresh");
|
|
4517
5717
|
this.fs.start(config.id, config.folder, []);
|
|
4518
5718
|
this.notifyChanged();
|
|
5719
|
+
this.emitGame({ type: "session.create" });
|
|
4519
5720
|
return this.toInfo(config);
|
|
4520
5721
|
}
|
|
4521
5722
|
close(id) {
|
|
@@ -4605,7 +5806,9 @@ class SessionManager {
|
|
|
4605
5806
|
async createCheckpoint(sessionId, label) {
|
|
4606
5807
|
const config = this.getConfig(sessionId);
|
|
4607
5808
|
if (!config) throw new Error("Unknown session");
|
|
4608
|
-
|
|
5809
|
+
const checkpoint = await createCheckpoint(config.folder, label);
|
|
5810
|
+
this.emitGame({ type: "checkpoint.create" });
|
|
5811
|
+
return checkpoint;
|
|
4609
5812
|
}
|
|
4610
5813
|
/** Recent checkpoints for a session's repo, newest first. */
|
|
4611
5814
|
async listCheckpoints(sessionId) {
|
|
@@ -4713,6 +5916,7 @@ class SessionManager {
|
|
|
4713
5916
|
}, INITIAL_PROMPT_DELAY_MS);
|
|
4714
5917
|
}
|
|
4715
5918
|
this.notifyChanged();
|
|
5919
|
+
this.emitGame({ type: "worktree.create" });
|
|
4716
5920
|
return this.toInfo(config);
|
|
4717
5921
|
}
|
|
4718
5922
|
/** Live git facts about a worktree task (uncommitted files, commits ahead). */
|
|
@@ -4794,7 +5998,12 @@ ${commit.output}` };
|
|
|
4794
5998
|
}
|
|
4795
5999
|
const result = await mergeBranch(baseFolder, branch, baseBranch);
|
|
4796
6000
|
if (!result.ok) return { ...result, autoCommitted };
|
|
4797
|
-
|
|
6001
|
+
const merged = { ...await this.pushAfterMerge(result, baseFolder, baseBranch), autoCommitted };
|
|
6002
|
+
this.emitGame({ type: "worktree.merge", meta: { commits: ahead ?? 0 } });
|
|
6003
|
+
if (this.state.features.some((f) => f.taskSessionId === sessionId)) {
|
|
6004
|
+
this.emitGame({ type: "feature.merge" });
|
|
6005
|
+
}
|
|
6006
|
+
return merged;
|
|
4798
6007
|
}
|
|
4799
6008
|
/** True if `branch` is the dedicated expansion branch of any auto-expand config. */
|
|
4800
6009
|
isAutoExpandBranch(branch) {
|
|
@@ -4889,6 +6098,7 @@ ${commit.output}` };
|
|
|
4889
6098
|
const title = prTitle(config.name, branch);
|
|
4890
6099
|
const body = prBody(config.name, branch, baseBranch);
|
|
4891
6100
|
const result = await createPullRequest(config.folder, branch, baseBranch, title, body);
|
|
6101
|
+
if (result.ok) this.emitGame({ type: "worktree.pr" });
|
|
4892
6102
|
return { ...result, autoCommitted };
|
|
4893
6103
|
}
|
|
4894
6104
|
// ---------- auto-complete (auto-merge / auto-PR when claude finishes) --------
|
|
@@ -5173,6 +6383,7 @@ ${err.message}`
|
|
|
5173
6383
|
const action = this.state.actions.find((a) => a.id === actionId);
|
|
5174
6384
|
const command = action?.command.trim();
|
|
5175
6385
|
if (!config || !action || !command) return null;
|
|
6386
|
+
this.emitGame({ type: "action.run" });
|
|
5176
6387
|
if (action.shell === "claude") return this.runClaudeAction(config, command);
|
|
5177
6388
|
let terminal = config.terminals.find((t) => t.actionId === action.id);
|
|
5178
6389
|
let respawned = false;
|
|
@@ -5415,6 +6626,11 @@ ${err.message}`
|
|
|
5415
6626
|
if (!config) return;
|
|
5416
6627
|
const terminal = config.terminals.find((t) => t.id === terminalId);
|
|
5417
6628
|
if (!terminal || terminal.kind !== "claude") return;
|
|
6629
|
+
const prevForAward = this.lastStatusForAward.get(terminalId);
|
|
6630
|
+
this.lastStatusForAward.set(terminalId, status);
|
|
6631
|
+
if (status === "done" && prevForAward === "working") {
|
|
6632
|
+
this.emitGame({ type: "session.turn" });
|
|
6633
|
+
}
|
|
5418
6634
|
if (status === "done" || status === "idle") this.scheduleQueueDispatch(config.id);
|
|
5419
6635
|
if (config.worktree?.autoComplete) {
|
|
5420
6636
|
if (status === "working") this.worktreeWorked.add(config.id);
|
|
@@ -6419,17 +7635,27 @@ if (!gotLock) {
|
|
|
6419
7635
|
electron.app.whenReady().then(() => {
|
|
6420
7636
|
electron.app.setAppUserModelId("com.pedroferreira.maestro");
|
|
6421
7637
|
persistence.load();
|
|
7638
|
+
const gameBus = new events.EventEmitter();
|
|
7639
|
+
const emitGame = (e) => {
|
|
7640
|
+
try {
|
|
7641
|
+
gameBus.emit("game", e);
|
|
7642
|
+
} catch {
|
|
7643
|
+
}
|
|
7644
|
+
};
|
|
6422
7645
|
const fsService = new FsService(
|
|
6423
|
-
(sessionId,
|
|
7646
|
+
(sessionId, events2) => getWin()?.webContents.send("fs:events", sessionId, events2),
|
|
6424
7647
|
() => persistence.state.settings.ignoreNames
|
|
6425
7648
|
);
|
|
6426
7649
|
const tokenEff = new TokenEfficiencyService(persistence);
|
|
6427
|
-
const sessions = new SessionManager(persistence, fsService, tokenEff, getWin);
|
|
6428
|
-
const sentinels = new SentinelService(persistence, getWin);
|
|
6429
|
-
const features = new FeatureService(persistence, sessions);
|
|
6430
|
-
const autoExpand = new AutoExpandService(persistence, features, getWin);
|
|
7650
|
+
const sessions = new SessionManager(persistence, fsService, tokenEff, getWin, emitGame);
|
|
7651
|
+
const sentinels = new SentinelService(persistence, getWin, emitGame);
|
|
7652
|
+
const features = new FeatureService(persistence, sessions, emitGame);
|
|
7653
|
+
const autoExpand = new AutoExpandService(persistence, features, getWin, emitGame);
|
|
6431
7654
|
const conductor = new ConductorService(persistence, sessions, features, autoExpand, getWin);
|
|
6432
|
-
const factory = new FactoryService(getWin);
|
|
7655
|
+
const factory = new FactoryService(getWin, emitGame);
|
|
7656
|
+
const agentRegistry = new AgentRegistryService(persistence, getWin);
|
|
7657
|
+
const gamification = new GamificationService(getWin);
|
|
7658
|
+
gameBus.on("game", (e) => gamification.award(e));
|
|
6433
7659
|
registerIpc(
|
|
6434
7660
|
sessions,
|
|
6435
7661
|
fsService,
|
|
@@ -6439,7 +7665,9 @@ if (!gotLock) {
|
|
|
6439
7665
|
autoExpand,
|
|
6440
7666
|
conductor,
|
|
6441
7667
|
factory,
|
|
7668
|
+
agentRegistry,
|
|
6442
7669
|
tokenEff,
|
|
7670
|
+
gamification,
|
|
6443
7671
|
getWin
|
|
6444
7672
|
);
|
|
6445
7673
|
createWindow();
|
|
@@ -6448,11 +7676,18 @@ if (!gotLock) {
|
|
|
6448
7676
|
sentinels.start();
|
|
6449
7677
|
autoExpand.start();
|
|
6450
7678
|
tokenEff.start();
|
|
7679
|
+
factory.start();
|
|
7680
|
+
conductor.onTurnComplete((messages) => {
|
|
7681
|
+
factory.considerConversation(messages);
|
|
7682
|
+
emitGame({ type: "conductor.turn" });
|
|
7683
|
+
});
|
|
6451
7684
|
electron.app.on("activate", () => {
|
|
6452
7685
|
if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
6453
7686
|
});
|
|
6454
7687
|
electron.app.on("before-quit", () => {
|
|
6455
7688
|
tokenEff.dispose();
|
|
7689
|
+
agentRegistry.dispose();
|
|
7690
|
+
gamification.dispose();
|
|
6456
7691
|
factory.dispose();
|
|
6457
7692
|
conductor.dispose();
|
|
6458
7693
|
autoExpand.dispose();
|