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