mastermind-md 0.1.0
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/.claude/skills/master/SKILL.md +10 -0
- package/.claude/skills/mastermind/SKILL.md +61 -0
- package/.claude/skills/mastermind/reference/demo.md +35 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/assets/agent/global.md +12 -0
- package/bin/mastermind.js +17 -0
- package/dist/cli/index.js +1284 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/server/index.js +1752 -0
- package/dist/server/index.js.map +1 -0
- package/dist/ui/assets/FindBar-CSKdPrxm.js +1 -0
- package/dist/ui/assets/RenameDialog-C6yP_6D8.js +1 -0
- package/dist/ui/assets/SettingsPanel-C6-wwvJr.js +1 -0
- package/dist/ui/assets/SourceEditor-DN44HaIZ.js +37 -0
- package/dist/ui/assets/TranslatedView-G29rKesM.js +1 -0
- package/dist/ui/assets/index-Bhf9eUP2.css +1 -0
- package/dist/ui/assets/index-cMAuoyhV.js +99 -0
- package/dist/ui/assets/jsx-runtime-BrnrUjgG.js +1 -0
- package/dist/ui/assets/react-DoK4WR2u.js +1 -0
- package/dist/ui/index.html +16 -0
- package/package.json +91 -0
- package/themes/carbon/theme.json +11 -0
- package/themes/carbon/tokens.css +67 -0
- package/themes/cobalt/theme.json +11 -0
- package/themes/cobalt/tokens.css +67 -0
- package/themes/fonts/BricolageGrotesque-Variable.woff2 +0 -0
- package/themes/fonts/CrimsonPro-Variable.woff2 +0 -0
- package/themes/fonts/Fraunces-Variable.woff2 +0 -0
- package/themes/fonts/GeistSans-Variable.woff2 +0 -0
- package/themes/fonts/HankenGrotesk-Variable.woff2 +0 -0
- package/themes/fonts/Inter-Variable.woff2 +0 -0
- package/themes/fonts/JetBrainsMono-Variable.woff2 +0 -0
- package/themes/fonts/Lora-Variable.woff2 +0 -0
- package/themes/fonts/Manrope-Variable.woff2 +0 -0
- package/themes/fonts/Newsreader-Variable.woff2 +0 -0
- package/themes/fonts/Outfit-Variable.woff2 +0 -0
- package/themes/fonts/SpaceGrotesk-Variable.woff2 +0 -0
- package/themes/fonts/SplineSansMono-Variable.woff2 +0 -0
- package/themes/fonts/UbuntuSansMono-Variable.woff2 +0 -0
- package/themes/grid/fonts/GeistMono-Variable.woff2 +0 -0
- package/themes/grid/fonts/SchibstedGrotesk-Variable.woff2 +0 -0
- package/themes/grid/theme.json +11 -0
- package/themes/grid/tokens.css +67 -0
- package/themes/nacht/theme.json +11 -0
- package/themes/nacht/tokens.css +67 -0
- package/themes/rose/theme.json +11 -0
- package/themes/rose/tokens.css +67 -0
- package/themes/sepia/theme.json +11 -0
- package/themes/sepia/tokens.css +67 -0
- package/themes/slate/theme.json +11 -0
- package/themes/slate/tokens.css +67 -0
|
@@ -0,0 +1,1752 @@
|
|
|
1
|
+
// src/server/index.ts
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import fsp from "fs/promises";
|
|
4
|
+
|
|
5
|
+
// package.json
|
|
6
|
+
var package_default = {
|
|
7
|
+
name: "mastermind-md",
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
type: "module",
|
|
10
|
+
description: "Review Markdown with your coding agent \u2014 local-first, CriticMarkup review loop, bilingual, the file is the protocol",
|
|
11
|
+
license: "MIT",
|
|
12
|
+
author: "Kevin Ding <kevincentding@gmail.com>",
|
|
13
|
+
homepage: "https://github.com/Jingquank/Mastermind#readme",
|
|
14
|
+
repository: {
|
|
15
|
+
type: "git",
|
|
16
|
+
url: "git+https://github.com/Jingquank/Mastermind.git"
|
|
17
|
+
},
|
|
18
|
+
bugs: {
|
|
19
|
+
url: "https://github.com/Jingquank/Mastermind/issues"
|
|
20
|
+
},
|
|
21
|
+
keywords: [
|
|
22
|
+
"markdown",
|
|
23
|
+
"review",
|
|
24
|
+
"criticmarkup",
|
|
25
|
+
"ai-agents",
|
|
26
|
+
"claude",
|
|
27
|
+
"cli",
|
|
28
|
+
"bilingual",
|
|
29
|
+
"local-first"
|
|
30
|
+
],
|
|
31
|
+
engines: {
|
|
32
|
+
node: ">=20"
|
|
33
|
+
},
|
|
34
|
+
bin: {
|
|
35
|
+
mastermind: "bin/mastermind.js",
|
|
36
|
+
"mastermind-md": "bin/mastermind.js"
|
|
37
|
+
},
|
|
38
|
+
files: [
|
|
39
|
+
"dist/",
|
|
40
|
+
"bin/",
|
|
41
|
+
"themes/",
|
|
42
|
+
".claude/skills/",
|
|
43
|
+
"assets/agent/",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
scripts: {
|
|
48
|
+
build: "rm -rf dist && vite build && tsup",
|
|
49
|
+
dev: 'concurrently -k "MASTERMIND_PORT=5199 tsx watch src/server/index.ts" "vite"',
|
|
50
|
+
test: "vitest run",
|
|
51
|
+
"test:watch": "vitest",
|
|
52
|
+
typecheck: "tsc -p tsconfig.json && tsc -p tsconfig.node.json",
|
|
53
|
+
prepare: "npm run build",
|
|
54
|
+
prepack: "npm run build"
|
|
55
|
+
},
|
|
56
|
+
dependencies: {
|
|
57
|
+
"@codemirror/lang-markdown": "^6.5.0",
|
|
58
|
+
"@codemirror/language": "^6.12.3",
|
|
59
|
+
"@codemirror/state": "^6.6.0",
|
|
60
|
+
"@codemirror/view": "^6.43.1",
|
|
61
|
+
"@hono/node-server": "^2.0.4",
|
|
62
|
+
"@lezer/highlight": "^1.2.3",
|
|
63
|
+
"@radix-ui/react-icons": "^1.3.2",
|
|
64
|
+
chokidar: "^5.0.0",
|
|
65
|
+
codemirror: "^6.0.2",
|
|
66
|
+
commander: "^15.0.0",
|
|
67
|
+
diff: "^9.0.0",
|
|
68
|
+
hono: "^4.12.25",
|
|
69
|
+
"mdast-util-to-markdown": "^2.1.2",
|
|
70
|
+
"radix-ui": "^1.5.0",
|
|
71
|
+
react: "^19.2.7",
|
|
72
|
+
"react-dom": "^19.2.7",
|
|
73
|
+
"remark-gfm": "^4.0.1",
|
|
74
|
+
"remark-parse": "^11.0.0",
|
|
75
|
+
"remark-stringify": "^11.0.0",
|
|
76
|
+
"slot-text": "^0.2.2",
|
|
77
|
+
unified: "^11.0.5",
|
|
78
|
+
"unist-util-visit": "^5.1.0",
|
|
79
|
+
zustand: "^5.0.14"
|
|
80
|
+
},
|
|
81
|
+
devDependencies: {
|
|
82
|
+
"@types/mdast": "^4.0.4",
|
|
83
|
+
"@types/node": "^25.9.3",
|
|
84
|
+
"@types/react": "^19.2.17",
|
|
85
|
+
"@types/react-dom": "^19.2.3",
|
|
86
|
+
"@types/unist": "^3.0.3",
|
|
87
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
88
|
+
concurrently: "^10.0.3",
|
|
89
|
+
jsdom: "^29.1.1",
|
|
90
|
+
tsup: "^8.5.1",
|
|
91
|
+
tsx: "^4.22.4",
|
|
92
|
+
typescript: "^6.0.3",
|
|
93
|
+
vite: "^8.0.16",
|
|
94
|
+
vitest: "^4.1.8"
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/shared/constants.ts
|
|
99
|
+
var DEFAULT_PORT = 5173;
|
|
100
|
+
var PORT_SCAN_LIMIT = 20;
|
|
101
|
+
var SSE_PING_INTERVAL_MS = 15e3;
|
|
102
|
+
var SESSION_CLOSE_GRACE_MS = 3e4;
|
|
103
|
+
var SESSION_NEVER_OPENED_MS = 12e4;
|
|
104
|
+
var DAEMON_IDLE_EXIT_MS = 30 * 6e4;
|
|
105
|
+
var DEFAULT_AUTHOR_TAG = "ke";
|
|
106
|
+
var ASSIST_TIMEOUT_MS = 12e4;
|
|
107
|
+
|
|
108
|
+
// src/server/app.ts
|
|
109
|
+
import { Hono } from "hono";
|
|
110
|
+
import { streamSSE } from "hono/streaming";
|
|
111
|
+
import fs11 from "fs/promises";
|
|
112
|
+
import os3 from "os";
|
|
113
|
+
import path8 from "path";
|
|
114
|
+
|
|
115
|
+
// src/server/config.ts
|
|
116
|
+
import fs2 from "fs";
|
|
117
|
+
import path2 from "path";
|
|
118
|
+
|
|
119
|
+
// src/server/paths.ts
|
|
120
|
+
import { fileURLToPath } from "url";
|
|
121
|
+
import path from "path";
|
|
122
|
+
import os from "os";
|
|
123
|
+
import fs from "fs";
|
|
124
|
+
var pkgRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
125
|
+
var uiDir = path.join(pkgRoot, "dist", "ui");
|
|
126
|
+
var themesDir = path.join(pkgRoot, "themes");
|
|
127
|
+
function configDir() {
|
|
128
|
+
return process.env.MASTERMIND_CONFIG_DIR ?? path.join(os.homedir(), ".config", "mastermind");
|
|
129
|
+
}
|
|
130
|
+
function ensureConfigDir() {
|
|
131
|
+
const dir = configDir();
|
|
132
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
133
|
+
return dir;
|
|
134
|
+
}
|
|
135
|
+
function stateFilePath() {
|
|
136
|
+
return path.join(configDir(), "server.json");
|
|
137
|
+
}
|
|
138
|
+
function configFilePath() {
|
|
139
|
+
return path.join(configDir(), "config.json");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/server/config.ts
|
|
143
|
+
var DEFAULT_CONFIG = {
|
|
144
|
+
version: 1,
|
|
145
|
+
theme: "grid",
|
|
146
|
+
fontSize: 16,
|
|
147
|
+
lineHeight: 1.6,
|
|
148
|
+
contentWidth: 736,
|
|
149
|
+
authorTag: DEFAULT_AUTHOR_TAG,
|
|
150
|
+
typeSet: "grid",
|
|
151
|
+
monoFont: "geist",
|
|
152
|
+
codeTheme: "none",
|
|
153
|
+
uiLang: "en",
|
|
154
|
+
langPair: { a: "English", b: "Simplified Chinese" },
|
|
155
|
+
browser: "",
|
|
156
|
+
grain: {}
|
|
157
|
+
};
|
|
158
|
+
function readConfig() {
|
|
159
|
+
try {
|
|
160
|
+
const raw = fs2.readFileSync(configFilePath(), "utf8");
|
|
161
|
+
const parsed = JSON.parse(raw);
|
|
162
|
+
return { ...DEFAULT_CONFIG, ...parsed, version: 1 };
|
|
163
|
+
} catch {
|
|
164
|
+
return { ...DEFAULT_CONFIG };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function persist(config) {
|
|
168
|
+
const dir = ensureConfigDir();
|
|
169
|
+
const tmp = path2.join(dir, `.config.json.${process.pid}.tmp`);
|
|
170
|
+
fs2.writeFileSync(tmp, JSON.stringify(config, null, 2));
|
|
171
|
+
fs2.renameSync(tmp, configFilePath());
|
|
172
|
+
}
|
|
173
|
+
function updateConfig(patch) {
|
|
174
|
+
const next = { ...readConfig(), ...patch, version: 1 };
|
|
175
|
+
persist(next);
|
|
176
|
+
return next;
|
|
177
|
+
}
|
|
178
|
+
function redactConfig(config) {
|
|
179
|
+
return config;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/server/contain.ts
|
|
183
|
+
import fs3 from "fs/promises";
|
|
184
|
+
import path3 from "path";
|
|
185
|
+
async function resolveWithin(rootReal, rel) {
|
|
186
|
+
if (path3.isAbsolute(rel)) return null;
|
|
187
|
+
const normalized = path3.normalize(rel);
|
|
188
|
+
if (normalized === ".." || normalized.startsWith(`..${path3.sep}`)) return null;
|
|
189
|
+
let real;
|
|
190
|
+
try {
|
|
191
|
+
real = await fs3.realpath(path3.resolve(rootReal, normalized));
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (real !== rootReal && !real.startsWith(rootReal + path3.sep)) return null;
|
|
196
|
+
return real;
|
|
197
|
+
}
|
|
198
|
+
function relWithin(rootReal, realPath) {
|
|
199
|
+
if (realPath === rootReal) return "";
|
|
200
|
+
if (!realPath.startsWith(rootReal + path3.sep)) return null;
|
|
201
|
+
return realPath.slice(rootReal.length + 1).split(path3.sep).join("/");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/server/feedback.ts
|
|
205
|
+
async function processFeedbackLanguage(content) {
|
|
206
|
+
return content;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/server/files.ts
|
|
210
|
+
import crypto from "crypto";
|
|
211
|
+
import fs4 from "fs/promises";
|
|
212
|
+
function sha256hex(content) {
|
|
213
|
+
return crypto.createHash("sha256").update(content, "utf8").digest("hex");
|
|
214
|
+
}
|
|
215
|
+
async function writeSessionFile(session, content, baseMtimeMs) {
|
|
216
|
+
if (baseMtimeMs !== void 0) {
|
|
217
|
+
const st2 = await fs4.stat(session.path).catch(() => null);
|
|
218
|
+
if (st2 && Math.abs(st2.mtimeMs - baseMtimeMs) > 1e-3) {
|
|
219
|
+
return { ok: false, currentMtimeMs: st2.mtimeMs };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
session.lastSelfWriteHash = sha256hex(content);
|
|
223
|
+
await fs4.writeFile(session.path, content);
|
|
224
|
+
const st = await fs4.stat(session.path);
|
|
225
|
+
return { ok: true, mtimeMs: st.mtimeMs };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/server/handback.ts
|
|
229
|
+
import fs5 from "fs/promises";
|
|
230
|
+
|
|
231
|
+
// src/shared/critic/scanner.ts
|
|
232
|
+
var OPENERS = [
|
|
233
|
+
["{++", "ins"],
|
|
234
|
+
["{--", "del"],
|
|
235
|
+
["{~~", "sub"],
|
|
236
|
+
["{==", "highlight"],
|
|
237
|
+
["{>>", "comment"]
|
|
238
|
+
];
|
|
239
|
+
var CLOSERS = {
|
|
240
|
+
ins: "++}",
|
|
241
|
+
del: "--}",
|
|
242
|
+
sub: "~~}",
|
|
243
|
+
highlight: "==}",
|
|
244
|
+
comment: "<<}"
|
|
245
|
+
};
|
|
246
|
+
function blankBreaks(text) {
|
|
247
|
+
const out = [];
|
|
248
|
+
const re = /\r?\n[ \t]*\r?\n/g;
|
|
249
|
+
let m;
|
|
250
|
+
while ((m = re.exec(text)) !== null) {
|
|
251
|
+
out.push(m.index);
|
|
252
|
+
re.lastIndex = m.index + 1;
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
function makeExcludeLookup(exclude) {
|
|
257
|
+
const sorted = [...exclude].sort((a, b) => a.start - b.start);
|
|
258
|
+
return (pos) => {
|
|
259
|
+
let lo = 0;
|
|
260
|
+
let hi = sorted.length - 1;
|
|
261
|
+
while (lo <= hi) {
|
|
262
|
+
const mid = lo + hi >> 1;
|
|
263
|
+
const r = sorted[mid];
|
|
264
|
+
if (pos < r.start) hi = mid - 1;
|
|
265
|
+
else if (pos >= r.end) lo = mid + 1;
|
|
266
|
+
else return r;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function escapedAt(text, bracePos) {
|
|
272
|
+
let backslashes = 0;
|
|
273
|
+
while (bracePos - 1 - backslashes >= 0 && text[bracePos - 1 - backslashes] === "\\") backslashes++;
|
|
274
|
+
return backslashes % 2 === 1;
|
|
275
|
+
}
|
|
276
|
+
function scan(text, exclude = []) {
|
|
277
|
+
const spans = [];
|
|
278
|
+
const breaks = blankBreaks(text);
|
|
279
|
+
const excludeAt = makeExcludeLookup(exclude);
|
|
280
|
+
let breakIdx = 0;
|
|
281
|
+
const findToken = (token, from, limit) => {
|
|
282
|
+
let k = from;
|
|
283
|
+
while (k < limit) {
|
|
284
|
+
const j = text.indexOf(token, k);
|
|
285
|
+
if (j === -1 || j >= limit) return -1;
|
|
286
|
+
const ex = excludeAt(j);
|
|
287
|
+
if (ex) {
|
|
288
|
+
k = ex.end;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
return j;
|
|
292
|
+
}
|
|
293
|
+
return -1;
|
|
294
|
+
};
|
|
295
|
+
let i = 0;
|
|
296
|
+
while (i < text.length) {
|
|
297
|
+
const brace = text.indexOf("{", i);
|
|
298
|
+
if (brace === -1) break;
|
|
299
|
+
i = brace;
|
|
300
|
+
const ex = excludeAt(i);
|
|
301
|
+
if (ex) {
|
|
302
|
+
i = ex.end;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (escapedAt(text, i)) {
|
|
306
|
+
i += 1;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const opener = OPENERS.find(([tok]) => text.startsWith(tok, i));
|
|
310
|
+
if (!opener) {
|
|
311
|
+
i += 1;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const kind = opener[1];
|
|
315
|
+
const innerStart = i + 3;
|
|
316
|
+
while (breakIdx < breaks.length && breaks[breakIdx] < innerStart) breakIdx++;
|
|
317
|
+
const limit = breakIdx < breaks.length ? breaks[breakIdx] : text.length;
|
|
318
|
+
if (kind === "sub") {
|
|
319
|
+
const firstCloser = findToken(CLOSERS.sub, innerStart, limit);
|
|
320
|
+
const sep = findToken("~>", innerStart, limit);
|
|
321
|
+
if (firstCloser === -1 || sep === -1 || sep >= firstCloser) {
|
|
322
|
+
i += 1;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
spans.push({
|
|
326
|
+
kind,
|
|
327
|
+
start: i,
|
|
328
|
+
end: firstCloser + 3,
|
|
329
|
+
innerStart,
|
|
330
|
+
innerEnd: firstCloser,
|
|
331
|
+
oldStart: innerStart,
|
|
332
|
+
oldEnd: sep,
|
|
333
|
+
newStart: sep + 2,
|
|
334
|
+
newEnd: firstCloser
|
|
335
|
+
});
|
|
336
|
+
i = firstCloser + 3;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const closer = findToken(CLOSERS[kind], innerStart, limit);
|
|
340
|
+
if (closer === -1) {
|
|
341
|
+
i += 1;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
spans.push({ kind, start: i, end: closer + 3, innerStart, innerEnd: closer });
|
|
345
|
+
i = closer + 3;
|
|
346
|
+
}
|
|
347
|
+
return spans;
|
|
348
|
+
}
|
|
349
|
+
var AUTHOR_RE = /^\s*@([^\s:@]{1,64}):\s?/;
|
|
350
|
+
function stripLinePrefixes(s) {
|
|
351
|
+
return s.replace(/\n[ \t]*(?:>[ \t]?)*/g, "\n");
|
|
352
|
+
}
|
|
353
|
+
function parseAuthor(content) {
|
|
354
|
+
const m = AUTHOR_RE.exec(content);
|
|
355
|
+
if (!m) return { author: null, body: content };
|
|
356
|
+
return { author: m[1], body: content.slice(m[0].length) };
|
|
357
|
+
}
|
|
358
|
+
function toEntry(span, text) {
|
|
359
|
+
const { author, body } = parseAuthor(stripLinePrefixes(text.slice(span.innerStart, span.innerEnd)));
|
|
360
|
+
return { span, author, body };
|
|
361
|
+
}
|
|
362
|
+
function group(spans, text) {
|
|
363
|
+
const items = [];
|
|
364
|
+
let i = 0;
|
|
365
|
+
while (i < spans.length) {
|
|
366
|
+
const s = spans[i];
|
|
367
|
+
if (s.kind === "ins" || s.kind === "del" || s.kind === "sub") {
|
|
368
|
+
items.push({ type: "suggestion", span: s });
|
|
369
|
+
i++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (s.kind === "highlight") {
|
|
373
|
+
const next = spans[i + 1];
|
|
374
|
+
if (!(next && next.kind === "comment" && next.start === s.end)) {
|
|
375
|
+
items.push({ type: "highlight", span: s });
|
|
376
|
+
i++;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const comments2 = [];
|
|
380
|
+
let j2 = i + 1;
|
|
381
|
+
let expectedStart2 = s.end;
|
|
382
|
+
while (spans[j2] && spans[j2].kind === "comment" && spans[j2].start === expectedStart2) {
|
|
383
|
+
comments2.push(toEntry(spans[j2], text));
|
|
384
|
+
expectedStart2 = spans[j2].end;
|
|
385
|
+
j2++;
|
|
386
|
+
}
|
|
387
|
+
items.push({ type: "thread", anchor: s, comments: comments2, start: s.start, end: expectedStart2 });
|
|
388
|
+
i = j2;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const comments = [toEntry(s, text)];
|
|
392
|
+
let j = i + 1;
|
|
393
|
+
let expectedStart = s.end;
|
|
394
|
+
while (spans[j] && spans[j].kind === "comment" && spans[j].start === expectedStart) {
|
|
395
|
+
comments.push(toEntry(spans[j], text));
|
|
396
|
+
expectedStart = spans[j].end;
|
|
397
|
+
j++;
|
|
398
|
+
}
|
|
399
|
+
items.push({ type: "thread", anchor: null, comments, start: s.start, end: expectedStart });
|
|
400
|
+
i = j;
|
|
401
|
+
}
|
|
402
|
+
return items;
|
|
403
|
+
}
|
|
404
|
+
function reviewCounts(items) {
|
|
405
|
+
let comments = 0;
|
|
406
|
+
let edits = 0;
|
|
407
|
+
let highlights = 0;
|
|
408
|
+
for (const item of items) {
|
|
409
|
+
if (item.type === "suggestion") edits++;
|
|
410
|
+
else if (item.type === "highlight") highlights++;
|
|
411
|
+
else comments += item.comments.length;
|
|
412
|
+
}
|
|
413
|
+
return { comments, edits, highlights };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/shared/markdown/processor.ts
|
|
417
|
+
import remarkGfm from "remark-gfm";
|
|
418
|
+
import remarkParse from "remark-parse";
|
|
419
|
+
import { unified } from "unified";
|
|
420
|
+
var processor = unified().use(remarkParse).use(remarkGfm).freeze();
|
|
421
|
+
function parseMarkdown(text) {
|
|
422
|
+
const tree = processor.parse(text);
|
|
423
|
+
return processor.runSync(tree);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/shared/markdown/exclusions.ts
|
|
427
|
+
var EXCLUDED_TYPES = /* @__PURE__ */ new Set(["code", "inlineCode", "html"]);
|
|
428
|
+
function codeRanges(text) {
|
|
429
|
+
const tree = parseMarkdown(text);
|
|
430
|
+
const out = [];
|
|
431
|
+
const visit = (node) => {
|
|
432
|
+
const start = node.position?.start?.offset;
|
|
433
|
+
const end = node.position?.end?.offset;
|
|
434
|
+
if (EXCLUDED_TYPES.has(node.type) && start !== void 0 && end !== void 0) {
|
|
435
|
+
out.push({ start, end });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const children = node.children;
|
|
439
|
+
if (children) for (const child of children) visit(child);
|
|
440
|
+
};
|
|
441
|
+
visit(tree);
|
|
442
|
+
return out.sort((a, b) => a.start - b.start);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/shared/summary.ts
|
|
446
|
+
var SUMMARY_OPEN = "<!-- mastermind:summary -->";
|
|
447
|
+
var SUMMARY_CLOSE = "<!-- /mastermind:summary -->";
|
|
448
|
+
var SUMMARY_RE = /[ \t]*<!-- mastermind:summary -->[\s\S]*?<!-- \/mastermind:summary -->[ \t]*\n?/g;
|
|
449
|
+
function stripSummary(text) {
|
|
450
|
+
return text.replace(SUMMARY_RE, "");
|
|
451
|
+
}
|
|
452
|
+
function countsPhrase(counts) {
|
|
453
|
+
const parts = [];
|
|
454
|
+
if (counts.comments > 0) parts.push(`${counts.comments} comment${counts.comments === 1 ? "" : "s"}`);
|
|
455
|
+
if (counts.edits > 0) parts.push(`${counts.edits} suggested edit${counts.edits === 1 ? "" : "s"}`);
|
|
456
|
+
if (counts.highlights > 0) parts.push(`${counts.highlights} highlight${counts.highlights === 1 ? "" : "s"}`);
|
|
457
|
+
return parts.join(", ");
|
|
458
|
+
}
|
|
459
|
+
function summaryLine(counts) {
|
|
460
|
+
const phrase = countsPhrase(counts);
|
|
461
|
+
return `mastermind: review complete \u2014 ${phrase || "no marks"}`;
|
|
462
|
+
}
|
|
463
|
+
function formatStamp(date) {
|
|
464
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
465
|
+
return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())} ${p(date.getHours())}:${p(date.getMinutes())}`;
|
|
466
|
+
}
|
|
467
|
+
function noteLines(note) {
|
|
468
|
+
const safe = (note ?? "").replace(/<!--|-->/g, "").trim();
|
|
469
|
+
if (!safe) return "";
|
|
470
|
+
const quoted = safe.split("\n").map((l) => `> ${l}`.trimEnd()).join("\n");
|
|
471
|
+
return `>
|
|
472
|
+
> **Note:** ${quoted.replace(/^> /, "")}
|
|
473
|
+
`;
|
|
474
|
+
}
|
|
475
|
+
function buildSummaryBlock(counts, date, note) {
|
|
476
|
+
const phrase = countsPhrase(counts);
|
|
477
|
+
const detail = phrase ? `${phrase}. Open the CriticMarkup marks above for details.` : "No inline marks \u2014 see the document itself for changes.";
|
|
478
|
+
return `${SUMMARY_OPEN}
|
|
479
|
+
> **Review summary** (${formatStamp(date)})
|
|
480
|
+
> ${detail}
|
|
481
|
+
${noteLines(note)}${SUMMARY_CLOSE}
|
|
482
|
+
`;
|
|
483
|
+
}
|
|
484
|
+
function upsertSummary(text, counts, date, note) {
|
|
485
|
+
const block = buildSummaryBlock(counts, date, note);
|
|
486
|
+
const firstIdx = text.indexOf(SUMMARY_OPEN);
|
|
487
|
+
if (firstIdx !== -1) {
|
|
488
|
+
const before = stripSummary(text.slice(0, firstIdx));
|
|
489
|
+
const after = stripSummary(text.slice(firstIdx));
|
|
490
|
+
return before + block + after;
|
|
491
|
+
}
|
|
492
|
+
let body = text;
|
|
493
|
+
if (body.length > 0 && !body.endsWith("\n")) body += "\n";
|
|
494
|
+
if (body.length > 0 && !body.endsWith("\n\n")) body += "\n";
|
|
495
|
+
return body + block;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/server/handback.ts
|
|
499
|
+
async function performHandback(session, rawContent, baseMtimeMs, note) {
|
|
500
|
+
if (baseMtimeMs !== void 0) {
|
|
501
|
+
const st2 = await fs5.stat(session.path).catch(() => null);
|
|
502
|
+
if (st2 && Math.abs(st2.mtimeMs - baseMtimeMs) > 1e-3) {
|
|
503
|
+
return { ok: false, currentMtimeMs: st2.mtimeMs };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const content = await processFeedbackLanguage(rawContent);
|
|
507
|
+
const body = stripSummary(content);
|
|
508
|
+
const counts = reviewCounts(group(scan(body, codeRanges(body)), body));
|
|
509
|
+
const final = upsertSummary(content, counts, /* @__PURE__ */ new Date(), note);
|
|
510
|
+
session.lastSelfWriteHash = sha256hex(final);
|
|
511
|
+
await fs5.writeFile(session.path, final);
|
|
512
|
+
const st = await fs5.stat(session.path);
|
|
513
|
+
return { ok: true, mtimeMs: st.mtimeMs, counts, summaryLine: summaryLine(counts) };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/server/browsers.ts
|
|
517
|
+
import fs6 from "fs";
|
|
518
|
+
import os2 from "os";
|
|
519
|
+
import path4 from "path";
|
|
520
|
+
var KNOWN_BROWSERS = [
|
|
521
|
+
"Safari",
|
|
522
|
+
"Google Chrome",
|
|
523
|
+
"Arc",
|
|
524
|
+
"Firefox",
|
|
525
|
+
"Brave Browser",
|
|
526
|
+
"Microsoft Edge",
|
|
527
|
+
"Vivaldi",
|
|
528
|
+
"Opera",
|
|
529
|
+
"Chromium",
|
|
530
|
+
"Zen"
|
|
531
|
+
];
|
|
532
|
+
function appExists(name) {
|
|
533
|
+
return [
|
|
534
|
+
`/Applications/${name}.app`,
|
|
535
|
+
`/System/Applications/${name}.app`,
|
|
536
|
+
path4.join(os2.homedir(), "Applications", `${name}.app`)
|
|
537
|
+
].some((p) => fs6.existsSync(p));
|
|
538
|
+
}
|
|
539
|
+
function detectBrowsers() {
|
|
540
|
+
if (process.platform !== "darwin") return [];
|
|
541
|
+
return KNOWN_BROWSERS.filter(appExists).map((name) => ({ id: name, name }));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/server/themes.ts
|
|
545
|
+
import fs7 from "fs/promises";
|
|
546
|
+
import path5 from "path";
|
|
547
|
+
|
|
548
|
+
// src/server/log.ts
|
|
549
|
+
function log(...args) {
|
|
550
|
+
console.error(`[${(/* @__PURE__ */ new Date()).toISOString()}]`, ...args);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/server/themes.ts
|
|
554
|
+
function parseSwatch(s) {
|
|
555
|
+
if (!s) return null;
|
|
556
|
+
const { bg, ink, accent } = s;
|
|
557
|
+
if (typeof bg === "string" && typeof ink === "string" && typeof accent === "string") {
|
|
558
|
+
return { bg, ink, accent };
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
function resolveFontUrl(themeId, src) {
|
|
563
|
+
if (src.startsWith("/themes/")) return src;
|
|
564
|
+
return `/themes/${themeId}${src.startsWith("/") ? "" : "/"}${src}`;
|
|
565
|
+
}
|
|
566
|
+
async function scanThemes() {
|
|
567
|
+
let entries;
|
|
568
|
+
try {
|
|
569
|
+
entries = await fs7.readdir(themesDir);
|
|
570
|
+
} catch {
|
|
571
|
+
return [];
|
|
572
|
+
}
|
|
573
|
+
const out = [];
|
|
574
|
+
for (const id of entries.sort()) {
|
|
575
|
+
const dir = path5.join(themesDir, id);
|
|
576
|
+
try {
|
|
577
|
+
const st = await fs7.stat(dir);
|
|
578
|
+
if (!st.isDirectory()) continue;
|
|
579
|
+
await fs7.access(path5.join(dir, "theme.json"));
|
|
580
|
+
} catch {
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
const [jsonRaw] = await Promise.all([
|
|
585
|
+
fs7.readFile(path5.join(dir, "theme.json"), "utf8"),
|
|
586
|
+
fs7.access(path5.join(dir, "tokens.css"))
|
|
587
|
+
// both files required
|
|
588
|
+
]);
|
|
589
|
+
const parsed = JSON.parse(jsonRaw);
|
|
590
|
+
const fonts = [];
|
|
591
|
+
for (const f of parsed.fonts ?? []) {
|
|
592
|
+
if (!f.family || !f.src) continue;
|
|
593
|
+
fonts.push({ family: f.family, url: resolveFontUrl(id, f.src), weight: f.weight ?? 400 });
|
|
594
|
+
}
|
|
595
|
+
out.push({
|
|
596
|
+
id,
|
|
597
|
+
name: parsed.name ?? id,
|
|
598
|
+
appearance: parsed.appearance === "dark" ? "dark" : "light",
|
|
599
|
+
grain: parsed.grain ? { enabled: parsed.grain.enabled ?? false, opacity: parsed.grain.opacity, tintOpacity: parsed.grain.tintOpacity } : null,
|
|
600
|
+
fonts,
|
|
601
|
+
swatch: parseSwatch(parsed.swatch)
|
|
602
|
+
});
|
|
603
|
+
} catch (err) {
|
|
604
|
+
log(`theme ${id} skipped:`, err instanceof Error ? err.message : err);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/shared/blocks.ts
|
|
611
|
+
function validateTranslatedBlock(original, translated) {
|
|
612
|
+
const kinds = (t) => scan(t, codeRanges(t)).map((s) => s.kind).join(",");
|
|
613
|
+
return kinds(original) === kinds(translated);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// src/server/assist/index.ts
|
|
617
|
+
import crypto2 from "crypto";
|
|
618
|
+
var AssistError = class extends Error {
|
|
619
|
+
constructor(code, detail) {
|
|
620
|
+
super(detail ? `${code}: ${detail}` : code);
|
|
621
|
+
this.code = code;
|
|
622
|
+
this.name = "AssistError";
|
|
623
|
+
}
|
|
624
|
+
code;
|
|
625
|
+
};
|
|
626
|
+
var AssistRegistry = class {
|
|
627
|
+
constructor(sessions, timeoutMs = ASSIST_TIMEOUT_MS) {
|
|
628
|
+
this.sessions = sessions;
|
|
629
|
+
this.timeoutMs = timeoutMs;
|
|
630
|
+
}
|
|
631
|
+
sessions;
|
|
632
|
+
timeoutMs;
|
|
633
|
+
pending = /* @__PURE__ */ new Map();
|
|
634
|
+
hasListener(session) {
|
|
635
|
+
return this.sessions.hasAssistListener(session);
|
|
636
|
+
}
|
|
637
|
+
enqueue(session, request, opts = {}) {
|
|
638
|
+
return this.enqueueWithId(session, request, opts).result;
|
|
639
|
+
}
|
|
640
|
+
/** Like enqueue, but exposes the request id synchronously (the suggest route returns it now, settles later). */
|
|
641
|
+
enqueueWithId(session, request, opts = {}) {
|
|
642
|
+
if (!this.hasListener(session)) return { id: "", result: Promise.reject(new AssistError("no-agent")) };
|
|
643
|
+
const id = crypto2.randomUUID();
|
|
644
|
+
const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
|
|
645
|
+
const result = new Promise((resolve, reject) => {
|
|
646
|
+
const timer = setTimeout(() => {
|
|
647
|
+
if (this.pending.delete(id)) {
|
|
648
|
+
session.pendingAssist.delete(id);
|
|
649
|
+
reject(new AssistError("timeout"));
|
|
650
|
+
}
|
|
651
|
+
}, timeoutMs);
|
|
652
|
+
this.pending.set(id, { id, sessionId: session.id, request, createdAt: Date.now(), resolve, reject, timer });
|
|
653
|
+
session.pendingAssist.add(id);
|
|
654
|
+
const event = { id, ...request };
|
|
655
|
+
this.sessions.broadcast(session.id, "assist-request", event, ["cli"]);
|
|
656
|
+
log(`assist: enqueued ${request.kind} ${id.slice(0, 8)} for session ${session.id}`);
|
|
657
|
+
});
|
|
658
|
+
return { id, result };
|
|
659
|
+
}
|
|
660
|
+
/** Agent delivered a result. Returns false if the id is unknown (expired / dup). */
|
|
661
|
+
resolve(id, payload) {
|
|
662
|
+
const p = this.pending.get(id);
|
|
663
|
+
if (!p) return false;
|
|
664
|
+
this.settle(p);
|
|
665
|
+
p.resolve(payload);
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
/** Agent declined/failed a request. */
|
|
669
|
+
fail(id, reason) {
|
|
670
|
+
const p = this.pending.get(id);
|
|
671
|
+
if (!p) return false;
|
|
672
|
+
this.settle(p);
|
|
673
|
+
p.reject(new AssistError("agent-error", reason));
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
/** Reject every pending request for a session (called on close). */
|
|
677
|
+
cancelForSession(sessionId) {
|
|
678
|
+
for (const p of [...this.pending.values()]) {
|
|
679
|
+
if (p.sessionId === sessionId) {
|
|
680
|
+
this.settle(p);
|
|
681
|
+
p.reject(new AssistError("cancelled"));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/** Requests still open for a session — drained by `mastermind assist` on (re)connect. */
|
|
686
|
+
pendingFor(sessionId) {
|
|
687
|
+
return [...this.pending.values()].filter((p) => p.sessionId === sessionId).map((p) => ({ id: p.id, ...p.request }));
|
|
688
|
+
}
|
|
689
|
+
settle(p) {
|
|
690
|
+
clearTimeout(p.timer);
|
|
691
|
+
this.pending.delete(p.id);
|
|
692
|
+
this.sessions.get(p.sessionId)?.pendingAssist.delete(p.id);
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// src/server/translate/cache.ts
|
|
697
|
+
import path6 from "path";
|
|
698
|
+
import fs8 from "fs/promises";
|
|
699
|
+
function cacheFile(realPath, targetLang) {
|
|
700
|
+
const safeLang = targetLang.replace(/[^a-zA-Z0-9_-]/g, "_") || "x";
|
|
701
|
+
return path6.join(path6.dirname(realPath), ".mastermind", "translations", `${path6.basename(realPath)}.${safeLang}.json`);
|
|
702
|
+
}
|
|
703
|
+
async function loadCache(realPath, targetLang) {
|
|
704
|
+
try {
|
|
705
|
+
const raw = await fs8.readFile(cacheFile(realPath, targetLang), "utf8");
|
|
706
|
+
const parsed = JSON.parse(raw);
|
|
707
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
708
|
+
} catch {
|
|
709
|
+
return {};
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
async function saveCache(realPath, targetLang, entries) {
|
|
713
|
+
if (Object.keys(entries).length === 0) return;
|
|
714
|
+
const file = cacheFile(realPath, targetLang);
|
|
715
|
+
try {
|
|
716
|
+
await fs8.mkdir(path6.dirname(file), { recursive: true });
|
|
717
|
+
const merged = { ...await loadCache(realPath, targetLang), ...entries };
|
|
718
|
+
await fs8.writeFile(file, JSON.stringify(merged));
|
|
719
|
+
} catch {
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// src/server/translate/index.ts
|
|
724
|
+
async function translateBlocks(session, blocks, sourceLang, targetLang, assist) {
|
|
725
|
+
if (blocks.length === 0) return { results: [] };
|
|
726
|
+
const cache = await loadCache(session.path, targetLang);
|
|
727
|
+
const results = [];
|
|
728
|
+
const misses = [];
|
|
729
|
+
for (const block of blocks) {
|
|
730
|
+
const hit = cache[block.hash];
|
|
731
|
+
if (hit !== void 0 && validateTranslatedBlock(block.text, hit)) {
|
|
732
|
+
results.push({ hash: block.hash, text: hit, cached: true });
|
|
733
|
+
} else {
|
|
734
|
+
misses.push(block);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (misses.length === 0) return { results };
|
|
738
|
+
const fresh = {};
|
|
739
|
+
let error;
|
|
740
|
+
try {
|
|
741
|
+
const payload = await assist.enqueue(session, { kind: "translate", sourceLang, targetLang, blocks: misses });
|
|
742
|
+
const byHash = new Map(payload.kind === "translate" ? payload.blocks.map((b) => [b.hash, b.text]) : []);
|
|
743
|
+
for (const block of misses) {
|
|
744
|
+
const text = byHash.get(block.hash);
|
|
745
|
+
if (text === void 0) results.push({ hash: block.hash, error: "provider", cached: false });
|
|
746
|
+
else if (!validateTranslatedBlock(block.text, text)) results.push({ hash: block.hash, error: "structure", cached: false });
|
|
747
|
+
else {
|
|
748
|
+
results.push({ hash: block.hash, text, cached: false });
|
|
749
|
+
fresh[block.hash] = text;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
} catch (err) {
|
|
753
|
+
error = err instanceof AssistError ? err.code : "provider";
|
|
754
|
+
for (const block of misses) results.push({ hash: block.hash, error, cached: false });
|
|
755
|
+
}
|
|
756
|
+
await saveCache(session.path, targetLang, fresh);
|
|
757
|
+
return { results, error };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/server/watch.ts
|
|
761
|
+
import { watch } from "chokidar";
|
|
762
|
+
import fs9 from "fs/promises";
|
|
763
|
+
function startWatcher(session, registry) {
|
|
764
|
+
const watcher = watch(session.path, {
|
|
765
|
+
ignoreInitial: true,
|
|
766
|
+
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
|
|
767
|
+
});
|
|
768
|
+
const onContent = async () => {
|
|
769
|
+
try {
|
|
770
|
+
const [content, st] = await Promise.all([fs9.readFile(session.path, "utf8"), fs9.stat(session.path)]);
|
|
771
|
+
if (sha256hex(content) === session.lastSelfWriteHash) return;
|
|
772
|
+
registry.broadcast(session.id, "file-changed", { mtimeMs: st.mtimeMs });
|
|
773
|
+
} catch {
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
watcher.on("change", () => void onContent());
|
|
777
|
+
watcher.on("add", () => void onContent());
|
|
778
|
+
watcher.on("unlink", () => {
|
|
779
|
+
setTimeout(() => {
|
|
780
|
+
void fs9.access(session.path).catch(() => {
|
|
781
|
+
log(`session ${session.id}: file deleted on disk`);
|
|
782
|
+
registry.broadcast(session.id, "file-deleted", {});
|
|
783
|
+
});
|
|
784
|
+
}, 500);
|
|
785
|
+
});
|
|
786
|
+
watcher.on("error", (err) => log(`watcher error for ${session.path}:`, err));
|
|
787
|
+
session.watcher = watcher;
|
|
788
|
+
}
|
|
789
|
+
async function stopWatcher(session) {
|
|
790
|
+
await session.watcher?.close().catch(() => {
|
|
791
|
+
});
|
|
792
|
+
session.watcher = null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// src/shared/edits.ts
|
|
796
|
+
function applyTextEdits(text, edits) {
|
|
797
|
+
const sorted = [...edits].sort((a, b) => b.from - a.from);
|
|
798
|
+
let out = text;
|
|
799
|
+
let lastFrom = Infinity;
|
|
800
|
+
for (const e of sorted) {
|
|
801
|
+
if (e.from > e.to) throw new Error(`invalid edit range ${e.from}..${e.to}`);
|
|
802
|
+
if (e.to > lastFrom) throw new Error("overlapping edits");
|
|
803
|
+
out = out.slice(0, e.from) + e.insert + out.slice(e.to);
|
|
804
|
+
lastFrom = e.from;
|
|
805
|
+
}
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/shared/critic/resolve.ts
|
|
810
|
+
function rejectEdit(text, span) {
|
|
811
|
+
switch (span.kind) {
|
|
812
|
+
case "ins":
|
|
813
|
+
return { from: span.start, to: span.end, insert: "" };
|
|
814
|
+
case "del":
|
|
815
|
+
return { from: span.start, to: span.end, insert: text.slice(span.innerStart, span.innerEnd) };
|
|
816
|
+
case "sub":
|
|
817
|
+
return { from: span.start, to: span.end, insert: text.slice(span.oldStart, span.oldEnd) };
|
|
818
|
+
case "highlight":
|
|
819
|
+
return { from: span.start, to: span.end, insert: text.slice(span.innerStart, span.innerEnd) };
|
|
820
|
+
case "comment":
|
|
821
|
+
return { from: span.start, to: span.end, insert: "" };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// src/shared/critic/suggest.ts
|
|
826
|
+
function validateSuggestion(markup, original) {
|
|
827
|
+
const spans = scan(markup, codeRanges(markup));
|
|
828
|
+
if (spans.length === 0) return { ok: false, spans, reason: "no-marks" };
|
|
829
|
+
if (!spans.every((s) => s.kind === "ins" || s.kind === "del" || s.kind === "sub")) {
|
|
830
|
+
return { ok: false, spans, reason: "wrong-kind" };
|
|
831
|
+
}
|
|
832
|
+
const rejected = applyTextEdits(
|
|
833
|
+
markup,
|
|
834
|
+
spans.map((s) => rejectEdit(markup, s))
|
|
835
|
+
);
|
|
836
|
+
if (rejected !== original) return { ok: false, spans, reason: "rewrites-original" };
|
|
837
|
+
return { ok: true, spans };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/server/static.ts
|
|
841
|
+
import fs10 from "fs/promises";
|
|
842
|
+
import path7 from "path";
|
|
843
|
+
var MIME = {
|
|
844
|
+
".html": "text/html; charset=utf-8",
|
|
845
|
+
".js": "text/javascript; charset=utf-8",
|
|
846
|
+
".css": "text/css; charset=utf-8",
|
|
847
|
+
".json": "application/json; charset=utf-8",
|
|
848
|
+
".map": "application/json",
|
|
849
|
+
".svg": "image/svg+xml",
|
|
850
|
+
".png": "image/png",
|
|
851
|
+
".ico": "image/x-icon",
|
|
852
|
+
".woff2": "font/woff2",
|
|
853
|
+
".woff": "font/woff",
|
|
854
|
+
".ttf": "font/ttf",
|
|
855
|
+
".txt": "text/plain; charset=utf-8",
|
|
856
|
+
".md": "text/markdown; charset=utf-8"
|
|
857
|
+
};
|
|
858
|
+
async function serveFile(root, relPath, cache = "no-cache") {
|
|
859
|
+
const abs = path7.normalize(path7.join(root, relPath));
|
|
860
|
+
if (abs !== root && !abs.startsWith(root + path7.sep)) return null;
|
|
861
|
+
let data;
|
|
862
|
+
try {
|
|
863
|
+
data = await fs10.readFile(abs);
|
|
864
|
+
} catch {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
const headers = {
|
|
868
|
+
"content-type": MIME[path7.extname(abs).toLowerCase()] ?? "application/octet-stream",
|
|
869
|
+
"cache-control": cache === "immutable" ? "public, max-age=31536000, immutable" : cache === "no-store" ? "no-store" : "no-cache"
|
|
870
|
+
};
|
|
871
|
+
return new Response(new Uint8Array(data), { status: 200, headers });
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// src/server/app.ts
|
|
875
|
+
var LOCAL_HOST_RE = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/;
|
|
876
|
+
var LOCAL_ORIGIN_RE = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/;
|
|
877
|
+
var WORKSPACE_DENYLIST = /* @__PURE__ */ new Set(["node_modules", ".git", ".mastermind"]);
|
|
878
|
+
function isHiddenEntry(name) {
|
|
879
|
+
return name.startsWith(".") || WORKSPACE_DENYLIST.has(name);
|
|
880
|
+
}
|
|
881
|
+
var fileMetaCache = /* @__PURE__ */ new Map();
|
|
882
|
+
async function workspaceFileMeta(rel, real, mtimeMs) {
|
|
883
|
+
const key = `${real}\0${mtimeMs}`;
|
|
884
|
+
const hit = fileMetaCache.get(key);
|
|
885
|
+
if (hit) return hit;
|
|
886
|
+
let counts = null;
|
|
887
|
+
if (real.toLowerCase().endsWith(".md")) {
|
|
888
|
+
try {
|
|
889
|
+
const body = stripSummary(await fs11.readFile(real, "utf8"));
|
|
890
|
+
counts = reviewCounts(group(scan(body, codeRanges(body)), body));
|
|
891
|
+
} catch {
|
|
892
|
+
counts = null;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const meta = { rel, counts, mtimeMs };
|
|
896
|
+
fileMetaCache.set(key, meta);
|
|
897
|
+
return meta;
|
|
898
|
+
}
|
|
899
|
+
function createApp(deps) {
|
|
900
|
+
const { registry, workspaces, assist } = deps;
|
|
901
|
+
const app = new Hono();
|
|
902
|
+
app.use("*", async (c, next) => {
|
|
903
|
+
deps.touch();
|
|
904
|
+
const host = c.req.header("host") ?? "";
|
|
905
|
+
if (!LOCAL_HOST_RE.test(host)) return c.text("forbidden host", 403);
|
|
906
|
+
const origin = c.req.header("origin");
|
|
907
|
+
if (origin && c.req.method !== "GET" && !LOCAL_ORIGIN_RE.test(origin)) {
|
|
908
|
+
return c.text("forbidden origin", 403);
|
|
909
|
+
}
|
|
910
|
+
await next();
|
|
911
|
+
});
|
|
912
|
+
app.get("/api/health", (c) => {
|
|
913
|
+
const body = {
|
|
914
|
+
ok: true,
|
|
915
|
+
pid: process.pid,
|
|
916
|
+
version: deps.version,
|
|
917
|
+
startedAt: deps.startedAt
|
|
918
|
+
};
|
|
919
|
+
return c.json(body);
|
|
920
|
+
});
|
|
921
|
+
app.post("/api/admin/shutdown", (c) => {
|
|
922
|
+
setTimeout(() => deps.requestShutdown("admin request"), 20);
|
|
923
|
+
return c.body(null, 202);
|
|
924
|
+
});
|
|
925
|
+
app.get("/api/config", (c) => c.json(redactConfig(readConfig())));
|
|
926
|
+
app.put("/api/config", async (c) => {
|
|
927
|
+
let patch;
|
|
928
|
+
try {
|
|
929
|
+
patch = await c.req.json();
|
|
930
|
+
} catch {
|
|
931
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
932
|
+
}
|
|
933
|
+
const next = updateConfig(patch);
|
|
934
|
+
for (const session of registry.all()) {
|
|
935
|
+
registry.broadcast(session.id, "config-changed", {});
|
|
936
|
+
}
|
|
937
|
+
return c.json(redactConfig(next));
|
|
938
|
+
});
|
|
939
|
+
app.get("/api/themes", async (c) => c.json(await scanThemes()));
|
|
940
|
+
app.get("/api/browsers", (c) => c.json(detectBrowsers()));
|
|
941
|
+
app.post("/api/translate", async (c) => {
|
|
942
|
+
let body;
|
|
943
|
+
try {
|
|
944
|
+
body = await c.req.json();
|
|
945
|
+
} catch {
|
|
946
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
947
|
+
}
|
|
948
|
+
if (!body.sessionId || !body.targetLang || !Array.isArray(body.blocks)) {
|
|
949
|
+
return c.json({ error: "sessionId, targetLang, blocks required" }, 400);
|
|
950
|
+
}
|
|
951
|
+
const session = registry.get(body.sessionId);
|
|
952
|
+
if (!session) return c.json({ error: "session not found" }, 404);
|
|
953
|
+
try {
|
|
954
|
+
const { results, error } = await translateBlocks(
|
|
955
|
+
session,
|
|
956
|
+
body.blocks.slice(0, 50),
|
|
957
|
+
body.sourceLang ?? "auto-detected source language",
|
|
958
|
+
body.targetLang,
|
|
959
|
+
assist
|
|
960
|
+
);
|
|
961
|
+
return c.json({ results, error });
|
|
962
|
+
} catch (err) {
|
|
963
|
+
const code = err instanceof AssistError ? err.code : "provider";
|
|
964
|
+
return c.json({ error: code }, 502);
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
app.post("/api/sessions", async (c) => {
|
|
968
|
+
let body;
|
|
969
|
+
try {
|
|
970
|
+
body = await c.req.json();
|
|
971
|
+
} catch {
|
|
972
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
973
|
+
}
|
|
974
|
+
let real;
|
|
975
|
+
let isDraft = false;
|
|
976
|
+
if (body.draft) {
|
|
977
|
+
const dir = body.dir ? path8.resolve(body.dir) : os3.homedir();
|
|
978
|
+
const dirStat = await fs11.stat(dir).catch(() => null);
|
|
979
|
+
if (!dirStat?.isDirectory()) return c.json({ error: `not a directory: ${dir}` }, 400);
|
|
980
|
+
let name = "untitled.md";
|
|
981
|
+
for (let n = 2; ; n++) {
|
|
982
|
+
try {
|
|
983
|
+
await fs11.writeFile(path8.join(dir, name), "", { flag: "wx" });
|
|
984
|
+
break;
|
|
985
|
+
} catch {
|
|
986
|
+
name = `untitled-${n}.md`;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
real = await fs11.realpath(path8.join(dir, name));
|
|
990
|
+
isDraft = true;
|
|
991
|
+
} else {
|
|
992
|
+
if (!body.path) return c.json({ error: "path is required" }, 400);
|
|
993
|
+
try {
|
|
994
|
+
real = await fs11.realpath(path8.resolve(body.path));
|
|
995
|
+
} catch {
|
|
996
|
+
return c.json({ error: `file not found: ${body.path}` }, 404);
|
|
997
|
+
}
|
|
998
|
+
const st = await fs11.stat(real);
|
|
999
|
+
if (!st.isFile()) return c.json({ error: `not a file: ${real}` }, 400);
|
|
1000
|
+
}
|
|
1001
|
+
const { session, created } = registry.open(real, { isDraft });
|
|
1002
|
+
const resp = {
|
|
1003
|
+
sessionId: session.id,
|
|
1004
|
+
url: `http://${c.req.header("host")}/d/${session.id}`,
|
|
1005
|
+
created,
|
|
1006
|
+
isDraft: session.isDraft
|
|
1007
|
+
};
|
|
1008
|
+
return c.json(resp);
|
|
1009
|
+
});
|
|
1010
|
+
app.get("/api/sessions/:id", async (c) => {
|
|
1011
|
+
const session = registry.get(c.req.param("id"));
|
|
1012
|
+
if (!session) return c.json({ error: "session not found" }, 404);
|
|
1013
|
+
let mtimeMs = 0;
|
|
1014
|
+
try {
|
|
1015
|
+
mtimeMs = (await fs11.stat(session.path)).mtimeMs;
|
|
1016
|
+
} catch {
|
|
1017
|
+
}
|
|
1018
|
+
const meta = {
|
|
1019
|
+
sessionId: session.id,
|
|
1020
|
+
path: session.path,
|
|
1021
|
+
displayName: session.displayName,
|
|
1022
|
+
isDraft: session.isDraft,
|
|
1023
|
+
mtimeMs,
|
|
1024
|
+
agentWaiting: session.cliConns.size > 0,
|
|
1025
|
+
assistAvailable: registry.hasAssistListener(session)
|
|
1026
|
+
};
|
|
1027
|
+
return c.json(meta);
|
|
1028
|
+
});
|
|
1029
|
+
app.get("/api/sessions/:id/file", async (c) => {
|
|
1030
|
+
const session = registry.get(c.req.param("id"));
|
|
1031
|
+
if (!session) return c.json({ error: "session not found" }, 404);
|
|
1032
|
+
try {
|
|
1033
|
+
const [content, st] = await Promise.all([fs11.readFile(session.path, "utf8"), fs11.stat(session.path)]);
|
|
1034
|
+
const body = { content, mtimeMs: st.mtimeMs };
|
|
1035
|
+
return c.json(body);
|
|
1036
|
+
} catch {
|
|
1037
|
+
return c.json({ error: "file unreadable (deleted?)" }, 410);
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
app.put("/api/sessions/:id/file", async (c) => {
|
|
1041
|
+
const session = registry.get(c.req.param("id"));
|
|
1042
|
+
if (!session) return c.json({ error: "session not found" }, 404);
|
|
1043
|
+
let body;
|
|
1044
|
+
try {
|
|
1045
|
+
body = await c.req.json();
|
|
1046
|
+
} catch {
|
|
1047
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
1048
|
+
}
|
|
1049
|
+
if (typeof body.content !== "string") return c.json({ error: "content is required" }, 400);
|
|
1050
|
+
const processed = await processFeedbackLanguage(body.content);
|
|
1051
|
+
const result = await writeSessionFile(session, processed, body.baseMtimeMs);
|
|
1052
|
+
if (!result.ok) {
|
|
1053
|
+
return c.json({ error: "file changed on disk", currentMtimeMs: result.currentMtimeMs }, 409);
|
|
1054
|
+
}
|
|
1055
|
+
workspaces.notifyForPath(session.path, "file-badge-changed");
|
|
1056
|
+
return c.json({ mtimeMs: result.mtimeMs, content: processed !== body.content ? processed : void 0 });
|
|
1057
|
+
});
|
|
1058
|
+
app.post("/api/sessions/:id/rename", async (c) => {
|
|
1059
|
+
const session = registry.get(c.req.param("id"));
|
|
1060
|
+
if (!session) return c.json({ error: "session not found" }, 404);
|
|
1061
|
+
let body;
|
|
1062
|
+
try {
|
|
1063
|
+
body = await c.req.json();
|
|
1064
|
+
} catch {
|
|
1065
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
1066
|
+
}
|
|
1067
|
+
let name = path8.basename((body.filename ?? "").trim());
|
|
1068
|
+
if (!name || name === ".md" || name.startsWith(".")) return c.json({ error: "invalid filename" }, 400);
|
|
1069
|
+
if (!name.endsWith(".md")) name += ".md";
|
|
1070
|
+
const target = path8.join(path8.dirname(session.path), name);
|
|
1071
|
+
const exists = await fs11.stat(target).then(
|
|
1072
|
+
() => true,
|
|
1073
|
+
() => false
|
|
1074
|
+
);
|
|
1075
|
+
if (exists) return c.json({ error: `${name} already exists`, code: "exists" }, 409);
|
|
1076
|
+
await stopWatcher(session);
|
|
1077
|
+
await fs11.rename(session.path, target);
|
|
1078
|
+
const real = await fs11.realpath(target);
|
|
1079
|
+
registry.rekey(session, real);
|
|
1080
|
+
session.isDraft = false;
|
|
1081
|
+
startWatcher(session, registry);
|
|
1082
|
+
log(`session ${session.id}: renamed to ${real}`);
|
|
1083
|
+
return c.json({ path: real, displayName: session.displayName });
|
|
1084
|
+
});
|
|
1085
|
+
app.post("/api/sessions/:id/handback", async (c) => {
|
|
1086
|
+
const session = registry.get(c.req.param("id"));
|
|
1087
|
+
if (!session) return c.json({ error: "session not found" }, 404);
|
|
1088
|
+
let body;
|
|
1089
|
+
try {
|
|
1090
|
+
body = await c.req.json();
|
|
1091
|
+
} catch {
|
|
1092
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
1093
|
+
}
|
|
1094
|
+
if (typeof body.content !== "string") return c.json({ error: "content is required" }, 400);
|
|
1095
|
+
const note = typeof body.note === "string" ? body.note : void 0;
|
|
1096
|
+
const result = await performHandback(session, body.content, body.baseMtimeMs, note);
|
|
1097
|
+
if (!result.ok) {
|
|
1098
|
+
return c.json({ error: "file changed on disk", currentMtimeMs: result.currentMtimeMs }, 409);
|
|
1099
|
+
}
|
|
1100
|
+
log(`session ${session.id}: hand back \u2014 ${result.summaryLine}`);
|
|
1101
|
+
registry.broadcast(session.id, "handback", {
|
|
1102
|
+
summaryLine: result.summaryLine,
|
|
1103
|
+
counts: result.counts,
|
|
1104
|
+
mtimeMs: result.mtimeMs
|
|
1105
|
+
});
|
|
1106
|
+
workspaces.notifyForPath(session.path, "file-badge-changed");
|
|
1107
|
+
return c.json({
|
|
1108
|
+
mtimeMs: result.mtimeMs,
|
|
1109
|
+
counts: result.counts,
|
|
1110
|
+
summaryLine: result.summaryLine
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
app.post("/api/workspaces", async (c) => {
|
|
1114
|
+
let body;
|
|
1115
|
+
try {
|
|
1116
|
+
body = await c.req.json();
|
|
1117
|
+
} catch {
|
|
1118
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
1119
|
+
}
|
|
1120
|
+
if (!body.root) return c.json({ error: "root is required" }, 400);
|
|
1121
|
+
let root;
|
|
1122
|
+
try {
|
|
1123
|
+
root = await fs11.realpath(path8.resolve(body.root));
|
|
1124
|
+
} catch {
|
|
1125
|
+
return c.json({ error: `directory not found: ${body.root}` }, 404);
|
|
1126
|
+
}
|
|
1127
|
+
if (!(await fs11.stat(root)).isDirectory()) return c.json({ error: `not a directory: ${root}` }, 400);
|
|
1128
|
+
const ws = workspaces.open(root);
|
|
1129
|
+
const resp = {
|
|
1130
|
+
workspaceId: ws.id,
|
|
1131
|
+
url: `http://${c.req.header("host")}/w/${ws.id}`,
|
|
1132
|
+
root: ws.root,
|
|
1133
|
+
displayName: ws.displayName
|
|
1134
|
+
};
|
|
1135
|
+
return c.json(resp);
|
|
1136
|
+
});
|
|
1137
|
+
app.get("/api/workspaces/:wid", (c) => {
|
|
1138
|
+
const ws = workspaces.get(c.req.param("wid"));
|
|
1139
|
+
if (!ws) return c.json({ error: "workspace not found" }, 404);
|
|
1140
|
+
const meta = { workspaceId: ws.id, root: ws.root, displayName: ws.displayName };
|
|
1141
|
+
return c.json(meta);
|
|
1142
|
+
});
|
|
1143
|
+
app.get("/api/workspaces/:wid/tree", async (c) => {
|
|
1144
|
+
const ws = workspaces.get(c.req.param("wid"));
|
|
1145
|
+
if (!ws) return c.json({ error: "workspace not found" }, 404);
|
|
1146
|
+
const dir = c.req.query("dir") ?? "";
|
|
1147
|
+
const real = await resolveWithin(ws.root, dir);
|
|
1148
|
+
if (!real) return c.json({ error: "forbidden path" }, 403);
|
|
1149
|
+
let dirents;
|
|
1150
|
+
try {
|
|
1151
|
+
dirents = await fs11.readdir(real, { withFileTypes: true });
|
|
1152
|
+
} catch {
|
|
1153
|
+
return c.json({ error: "not a directory" }, 404);
|
|
1154
|
+
}
|
|
1155
|
+
const entries = [];
|
|
1156
|
+
for (const d of dirents) {
|
|
1157
|
+
if (isHiddenEntry(d.name)) continue;
|
|
1158
|
+
const rel = dir ? `${dir}/${d.name}` : d.name;
|
|
1159
|
+
let isDir = d.isDirectory();
|
|
1160
|
+
if (d.isSymbolicLink()) {
|
|
1161
|
+
const target = await resolveWithin(ws.root, rel);
|
|
1162
|
+
if (!target) continue;
|
|
1163
|
+
try {
|
|
1164
|
+
isDir = (await fs11.stat(target)).isDirectory();
|
|
1165
|
+
} catch {
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
entries.push({ name: d.name, rel, isDir, isMarkdown: !isDir && d.name.toLowerCase().endsWith(".md") });
|
|
1170
|
+
}
|
|
1171
|
+
entries.sort((a, b) => a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1);
|
|
1172
|
+
const resp = { dir, entries };
|
|
1173
|
+
return c.json(resp);
|
|
1174
|
+
});
|
|
1175
|
+
app.get("/api/workspaces/:wid/file-meta", async (c) => {
|
|
1176
|
+
const ws = workspaces.get(c.req.param("wid"));
|
|
1177
|
+
if (!ws) return c.json({ error: "workspace not found" }, 404);
|
|
1178
|
+
const rel = c.req.query("path") ?? "";
|
|
1179
|
+
const real = await resolveWithin(ws.root, rel);
|
|
1180
|
+
if (!real) return c.json({ error: "forbidden path" }, 403);
|
|
1181
|
+
let st;
|
|
1182
|
+
try {
|
|
1183
|
+
st = await fs11.stat(real);
|
|
1184
|
+
} catch {
|
|
1185
|
+
return c.json({ error: "unreadable" }, 410);
|
|
1186
|
+
}
|
|
1187
|
+
if (!st.isFile()) return c.json({ error: "not a file" }, 400);
|
|
1188
|
+
return c.json(await workspaceFileMeta(rel, real, st.mtimeMs));
|
|
1189
|
+
});
|
|
1190
|
+
app.get("/api/workspaces/:wid/sessions", (c) => {
|
|
1191
|
+
const ws = workspaces.get(c.req.param("wid"));
|
|
1192
|
+
if (!ws) return c.json({ error: "workspace not found" }, 404);
|
|
1193
|
+
const out = [];
|
|
1194
|
+
for (const s of registry.all()) {
|
|
1195
|
+
const rel = relWithin(ws.root, s.path);
|
|
1196
|
+
if (rel === null) continue;
|
|
1197
|
+
out.push({
|
|
1198
|
+
rel,
|
|
1199
|
+
sessionId: s.id,
|
|
1200
|
+
agentWaiting: s.cliConns.size > 0,
|
|
1201
|
+
assistAvailable: registry.hasAssistListener(s),
|
|
1202
|
+
isDraft: s.isDraft
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
return c.json(out);
|
|
1206
|
+
});
|
|
1207
|
+
app.get("/api/workspaces/:wid/events", (c) => {
|
|
1208
|
+
const wid = c.req.param("wid");
|
|
1209
|
+
if (!workspaces.get(wid)) return c.json({ error: "workspace not found" }, 404);
|
|
1210
|
+
return streamSSE(c, async (stream) => {
|
|
1211
|
+
let open = true;
|
|
1212
|
+
const conn = {
|
|
1213
|
+
role: "ui",
|
|
1214
|
+
send: async (event, data) => {
|
|
1215
|
+
await stream.writeSSE({ event, data: JSON.stringify(data) });
|
|
1216
|
+
},
|
|
1217
|
+
close: () => {
|
|
1218
|
+
open = false;
|
|
1219
|
+
void stream.close();
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
stream.onAbort(() => {
|
|
1223
|
+
open = false;
|
|
1224
|
+
workspaces.detach(wid, conn);
|
|
1225
|
+
});
|
|
1226
|
+
if (!workspaces.attach(wid, conn)) {
|
|
1227
|
+
open = false;
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
log(`sse ui connected to workspace ${wid}`);
|
|
1231
|
+
while (open) {
|
|
1232
|
+
await stream.sleep(SSE_PING_INTERVAL_MS);
|
|
1233
|
+
if (!open) break;
|
|
1234
|
+
try {
|
|
1235
|
+
await stream.writeSSE({ event: "ping", data: "{}" });
|
|
1236
|
+
} catch {
|
|
1237
|
+
open = false;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
workspaces.detach(wid, conn);
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1243
|
+
const suggestions = /* @__PURE__ */ new Map();
|
|
1244
|
+
app.post("/api/assist", async (c) => {
|
|
1245
|
+
let body;
|
|
1246
|
+
try {
|
|
1247
|
+
body = await c.req.json();
|
|
1248
|
+
} catch {
|
|
1249
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
1250
|
+
}
|
|
1251
|
+
if (!body.sessionId || body.kind !== "suggest" || typeof body.selection !== "string") {
|
|
1252
|
+
return c.json({ error: "sessionId, kind:suggest, selection required" }, 400);
|
|
1253
|
+
}
|
|
1254
|
+
const session = registry.get(body.sessionId);
|
|
1255
|
+
if (!session) return c.json({ error: "session not found" }, 404);
|
|
1256
|
+
if (!assist.hasListener(session)) return c.json({ error: "no-agent" }, 409);
|
|
1257
|
+
const scope = body.scope === "section" || body.scope === "document" ? body.scope : "selection";
|
|
1258
|
+
const selection = body.selection;
|
|
1259
|
+
const { id, result } = assist.enqueueWithId(session, { kind: "suggest", scope, selection, context: body.context });
|
|
1260
|
+
result.then((payload) => {
|
|
1261
|
+
const ok = payload.kind === "suggest" && validateSuggestion(payload.markup, selection).ok;
|
|
1262
|
+
if (ok && payload.kind === "suggest") suggestions.set(id, { markup: payload.markup });
|
|
1263
|
+
registry.broadcast(session.id, "assist-result", { id, kind: "suggest", ok }, ["ui"]);
|
|
1264
|
+
}).catch((err) => {
|
|
1265
|
+
log(`assist suggest ${id.slice(0, 8)} failed: ${err instanceof AssistError ? err.code : "error"}`);
|
|
1266
|
+
registry.broadcast(session.id, "assist-result", { id, kind: "suggest", ok: false }, ["ui"]);
|
|
1267
|
+
});
|
|
1268
|
+
return c.json({ requestId: id });
|
|
1269
|
+
});
|
|
1270
|
+
app.get("/api/assist/:id/suggestion", (c) => {
|
|
1271
|
+
const s = suggestions.get(c.req.param("id"));
|
|
1272
|
+
if (!s) return c.json({ error: "not found" }, 404);
|
|
1273
|
+
suggestions.delete(c.req.param("id"));
|
|
1274
|
+
return c.json({ markup: s.markup });
|
|
1275
|
+
});
|
|
1276
|
+
app.get("/api/assist/pending", (c) => {
|
|
1277
|
+
const sessionId = c.req.query("sessionId");
|
|
1278
|
+
const session = sessionId ? registry.get(sessionId) : void 0;
|
|
1279
|
+
if (!session) return c.json({ error: "session not found" }, 404);
|
|
1280
|
+
return c.json({ requests: assist.pendingFor(session.id) });
|
|
1281
|
+
});
|
|
1282
|
+
app.post("/api/assist/:id/result", async (c) => {
|
|
1283
|
+
let payload;
|
|
1284
|
+
try {
|
|
1285
|
+
payload = await c.req.json();
|
|
1286
|
+
} catch {
|
|
1287
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
1288
|
+
}
|
|
1289
|
+
return assist.resolve(c.req.param("id"), payload) ? c.body(null, 204) : c.json({ error: "unknown or expired" }, 404);
|
|
1290
|
+
});
|
|
1291
|
+
app.post("/api/assist/:id/error", async (c) => {
|
|
1292
|
+
let body;
|
|
1293
|
+
try {
|
|
1294
|
+
body = await c.req.json();
|
|
1295
|
+
} catch {
|
|
1296
|
+
body = {};
|
|
1297
|
+
}
|
|
1298
|
+
return assist.fail(c.req.param("id"), body.reason ?? "declined") ? c.body(null, 204) : c.json({ error: "unknown or expired" }, 404);
|
|
1299
|
+
});
|
|
1300
|
+
app.get("/api/sessions/:id/events", (c) => {
|
|
1301
|
+
const sessionId = c.req.param("id");
|
|
1302
|
+
const role = c.req.query("role") === "cli" ? "cli" : "ui";
|
|
1303
|
+
const assistCapable = role === "cli" && c.req.query("assist") === "1";
|
|
1304
|
+
if (!registry.get(sessionId)) return c.json({ error: "session not found" }, 404);
|
|
1305
|
+
return streamSSE(c, async (stream) => {
|
|
1306
|
+
let open = true;
|
|
1307
|
+
const conn = {
|
|
1308
|
+
role,
|
|
1309
|
+
assistCapable,
|
|
1310
|
+
send: async (event, data) => {
|
|
1311
|
+
await stream.writeSSE({ event, data: JSON.stringify(data) });
|
|
1312
|
+
},
|
|
1313
|
+
close: () => {
|
|
1314
|
+
open = false;
|
|
1315
|
+
void stream.close();
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
stream.onAbort(() => {
|
|
1319
|
+
open = false;
|
|
1320
|
+
registry.detach(sessionId, conn);
|
|
1321
|
+
});
|
|
1322
|
+
if (!registry.attach(sessionId, conn)) {
|
|
1323
|
+
open = false;
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
log(`sse ${role} connected to session ${sessionId}`);
|
|
1327
|
+
while (open) {
|
|
1328
|
+
await stream.sleep(SSE_PING_INTERVAL_MS);
|
|
1329
|
+
if (!open) break;
|
|
1330
|
+
try {
|
|
1331
|
+
await stream.writeSSE({ event: "ping", data: "{}" });
|
|
1332
|
+
} catch {
|
|
1333
|
+
open = false;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
registry.detach(sessionId, conn);
|
|
1337
|
+
});
|
|
1338
|
+
});
|
|
1339
|
+
app.get("/themes/*", async (c) => {
|
|
1340
|
+
const rel = c.req.path.replace(/^\/themes\//, "");
|
|
1341
|
+
const res = await serveFile(themesDir, rel, "no-cache");
|
|
1342
|
+
return res ?? c.notFound();
|
|
1343
|
+
});
|
|
1344
|
+
app.get("/assets/*", async (c) => {
|
|
1345
|
+
const rel = c.req.path.replace(/^\//, "");
|
|
1346
|
+
const res = await serveFile(uiDir, rel, "immutable");
|
|
1347
|
+
return res ?? c.notFound();
|
|
1348
|
+
});
|
|
1349
|
+
app.get("*", async (c) => {
|
|
1350
|
+
if (c.req.path.startsWith("/api/")) return c.notFound();
|
|
1351
|
+
const rel = c.req.path.replace(/^\//, "");
|
|
1352
|
+
if (rel && !rel.includes("..") && path8.extname(rel)) {
|
|
1353
|
+
const res = await serveFile(uiDir, rel, "no-cache");
|
|
1354
|
+
if (res) return res;
|
|
1355
|
+
return c.notFound();
|
|
1356
|
+
}
|
|
1357
|
+
const index = await serveFile(uiDir, "index.html", "no-store");
|
|
1358
|
+
return index ?? c.text("mastermind: UI not built \u2014 run npm run build", 500);
|
|
1359
|
+
});
|
|
1360
|
+
return app;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// src/server/sessions.ts
|
|
1364
|
+
import path9 from "path";
|
|
1365
|
+
import fs12 from "fs/promises";
|
|
1366
|
+
import crypto3 from "crypto";
|
|
1367
|
+
async function pruneStaleHistory(realPath) {
|
|
1368
|
+
const dir = path9.dirname(realPath);
|
|
1369
|
+
const mastermind = path9.join(dir, ".mastermind");
|
|
1370
|
+
try {
|
|
1371
|
+
await fs12.rm(path9.join(mastermind, "history", path9.basename(realPath)), { recursive: true, force: true });
|
|
1372
|
+
await fs12.rmdir(path9.join(mastermind, "history")).catch(() => {
|
|
1373
|
+
});
|
|
1374
|
+
await fs12.rmdir(mastermind).catch(() => {
|
|
1375
|
+
});
|
|
1376
|
+
} catch {
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
var SessionRegistry = class {
|
|
1380
|
+
byPath = /* @__PURE__ */ new Map();
|
|
1381
|
+
byId = /* @__PURE__ */ new Map();
|
|
1382
|
+
graceMs;
|
|
1383
|
+
neverOpenedMs;
|
|
1384
|
+
constructor(timers) {
|
|
1385
|
+
this.graceMs = timers?.graceMs ?? SESSION_CLOSE_GRACE_MS;
|
|
1386
|
+
this.neverOpenedMs = timers?.neverOpenedMs ?? SESSION_NEVER_OPENED_MS;
|
|
1387
|
+
}
|
|
1388
|
+
/** Called whenever a session is created or destroyed (idle-exit tracking). */
|
|
1389
|
+
onChange = null;
|
|
1390
|
+
/** Hook for setup when a session opens (start file watcher). */
|
|
1391
|
+
onSessionOpened = null;
|
|
1392
|
+
/** Hook for cleanup when a session closes (stop file watcher). */
|
|
1393
|
+
onSessionClosed = null;
|
|
1394
|
+
/** Hook when a session's tree-visible state changes (agent attach/detach) — lets workspaces refresh badges. */
|
|
1395
|
+
onSessionActivity = null;
|
|
1396
|
+
open(realPath, opts = {}) {
|
|
1397
|
+
const existing = this.byPath.get(realPath);
|
|
1398
|
+
if (existing) return { session: existing, created: false };
|
|
1399
|
+
const session = {
|
|
1400
|
+
id: crypto3.randomUUID(),
|
|
1401
|
+
path: realPath,
|
|
1402
|
+
displayName: path9.basename(realPath),
|
|
1403
|
+
isDraft: opts.isDraft ?? false,
|
|
1404
|
+
createdAt: Date.now(),
|
|
1405
|
+
lastSelfWriteHash: null,
|
|
1406
|
+
watcher: null,
|
|
1407
|
+
pendingAssist: /* @__PURE__ */ new Set(),
|
|
1408
|
+
uiConns: /* @__PURE__ */ new Set(),
|
|
1409
|
+
cliConns: /* @__PURE__ */ new Set(),
|
|
1410
|
+
everConnected: false,
|
|
1411
|
+
graceTimer: null,
|
|
1412
|
+
graceArmedAt: 0,
|
|
1413
|
+
graceRearmed: false,
|
|
1414
|
+
neverOpenedTimer: null,
|
|
1415
|
+
closed: false
|
|
1416
|
+
};
|
|
1417
|
+
session.neverOpenedTimer = setTimeout(() => {
|
|
1418
|
+
if (!session.everConnected && !session.closed) {
|
|
1419
|
+
log(`session ${session.id} (${session.displayName}): no browser connected within timeout`);
|
|
1420
|
+
this.close(session.id, "never-opened");
|
|
1421
|
+
}
|
|
1422
|
+
}, this.neverOpenedMs);
|
|
1423
|
+
this.byPath.set(realPath, session);
|
|
1424
|
+
this.byId.set(session.id, session);
|
|
1425
|
+
if (!session.isDraft) void pruneStaleHistory(realPath);
|
|
1426
|
+
log(`session ${session.id} opened for ${realPath}`);
|
|
1427
|
+
this.onSessionOpened?.(session);
|
|
1428
|
+
this.onChange?.();
|
|
1429
|
+
return { session, created: true };
|
|
1430
|
+
}
|
|
1431
|
+
get(id) {
|
|
1432
|
+
return this.byId.get(id);
|
|
1433
|
+
}
|
|
1434
|
+
getByPath(realPath) {
|
|
1435
|
+
return this.byPath.get(realPath);
|
|
1436
|
+
}
|
|
1437
|
+
count() {
|
|
1438
|
+
return this.byId.size;
|
|
1439
|
+
}
|
|
1440
|
+
all() {
|
|
1441
|
+
return [...this.byId.values()];
|
|
1442
|
+
}
|
|
1443
|
+
/** Re-key a session after rename-on-first-save. */
|
|
1444
|
+
rekey(session, newRealPath) {
|
|
1445
|
+
this.byPath.delete(session.path);
|
|
1446
|
+
const mutable = session;
|
|
1447
|
+
mutable.path = newRealPath;
|
|
1448
|
+
mutable.displayName = path9.basename(newRealPath);
|
|
1449
|
+
this.byPath.set(newRealPath, session);
|
|
1450
|
+
}
|
|
1451
|
+
attach(sessionId, conn) {
|
|
1452
|
+
const session = this.byId.get(sessionId);
|
|
1453
|
+
if (!session || session.closed) return false;
|
|
1454
|
+
if (conn.role === "ui") {
|
|
1455
|
+
session.uiConns.add(conn);
|
|
1456
|
+
session.everConnected = true;
|
|
1457
|
+
if (session.neverOpenedTimer) {
|
|
1458
|
+
clearTimeout(session.neverOpenedTimer);
|
|
1459
|
+
session.neverOpenedTimer = null;
|
|
1460
|
+
}
|
|
1461
|
+
this.disarmGrace(session);
|
|
1462
|
+
} else {
|
|
1463
|
+
session.cliConns.add(conn);
|
|
1464
|
+
this.broadcast(sessionId, "waiters-changed", { count: session.cliConns.size }, ["ui"]);
|
|
1465
|
+
if (conn.assistCapable) {
|
|
1466
|
+
this.broadcast(sessionId, "assist-availability", { available: true }, ["ui"]);
|
|
1467
|
+
}
|
|
1468
|
+
this.onSessionActivity?.(session);
|
|
1469
|
+
}
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
/** Any assist-capable cli conn currently listening on this session. */
|
|
1473
|
+
hasAssistListener(session) {
|
|
1474
|
+
for (const c of session.cliConns) if (c.assistCapable) return true;
|
|
1475
|
+
return false;
|
|
1476
|
+
}
|
|
1477
|
+
detach(sessionId, conn) {
|
|
1478
|
+
const session = this.byId.get(sessionId);
|
|
1479
|
+
if (!session) return;
|
|
1480
|
+
const hadCli = session.cliConns.has(conn);
|
|
1481
|
+
session.uiConns.delete(conn);
|
|
1482
|
+
session.cliConns.delete(conn);
|
|
1483
|
+
if (hadCli && !session.closed) {
|
|
1484
|
+
this.broadcast(sessionId, "waiters-changed", { count: session.cliConns.size }, ["ui"]);
|
|
1485
|
+
if (conn.assistCapable) {
|
|
1486
|
+
this.broadcast(sessionId, "assist-availability", { available: this.hasAssistListener(session) }, ["ui"]);
|
|
1487
|
+
}
|
|
1488
|
+
this.onSessionActivity?.(session);
|
|
1489
|
+
}
|
|
1490
|
+
if (conn.role === "ui" && session.uiConns.size === 0 && session.everConnected && !session.closed) {
|
|
1491
|
+
this.armGrace(session);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
broadcast(sessionId, event, data, roles = ["ui", "cli"]) {
|
|
1495
|
+
const session = this.byId.get(sessionId);
|
|
1496
|
+
if (!session) return;
|
|
1497
|
+
const targets = [];
|
|
1498
|
+
if (roles.includes("ui")) targets.push(...session.uiConns);
|
|
1499
|
+
if (roles.includes("cli")) targets.push(...session.cliConns);
|
|
1500
|
+
for (const conn of targets) {
|
|
1501
|
+
conn.send(event, data).catch(() => {
|
|
1502
|
+
this.detach(sessionId, conn);
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
close(sessionId, reason) {
|
|
1507
|
+
const session = this.byId.get(sessionId);
|
|
1508
|
+
if (!session || session.closed) return;
|
|
1509
|
+
session.closed = true;
|
|
1510
|
+
if (session.graceTimer) clearTimeout(session.graceTimer);
|
|
1511
|
+
if (session.neverOpenedTimer) clearTimeout(session.neverOpenedTimer);
|
|
1512
|
+
this.broadcast(sessionId, "session-closed", { reason });
|
|
1513
|
+
for (const conn of [...session.uiConns, ...session.cliConns]) {
|
|
1514
|
+
setTimeout(() => conn.close(), 50);
|
|
1515
|
+
}
|
|
1516
|
+
session.uiConns.clear();
|
|
1517
|
+
session.cliConns.clear();
|
|
1518
|
+
this.byPath.delete(session.path);
|
|
1519
|
+
this.byId.delete(sessionId);
|
|
1520
|
+
log(`session ${sessionId} closed (${reason})`);
|
|
1521
|
+
this.onSessionClosed?.(session, reason);
|
|
1522
|
+
this.onChange?.();
|
|
1523
|
+
}
|
|
1524
|
+
closeAll(reason) {
|
|
1525
|
+
for (const id of [...this.byId.keys()]) this.close(id, reason);
|
|
1526
|
+
}
|
|
1527
|
+
armGrace(session) {
|
|
1528
|
+
this.disarmGrace(session);
|
|
1529
|
+
session.graceArmedAt = Date.now();
|
|
1530
|
+
session.graceRearmed = false;
|
|
1531
|
+
session.graceTimer = setTimeout(() => this.graceFired(session), this.graceMs);
|
|
1532
|
+
}
|
|
1533
|
+
disarmGrace(session) {
|
|
1534
|
+
if (session.graceTimer) {
|
|
1535
|
+
clearTimeout(session.graceTimer);
|
|
1536
|
+
session.graceTimer = null;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
graceFired(session) {
|
|
1540
|
+
session.graceTimer = null;
|
|
1541
|
+
if (session.closed || session.uiConns.size > 0) return;
|
|
1542
|
+
const elapsed = Date.now() - session.graceArmedAt;
|
|
1543
|
+
if (elapsed > this.graceMs * 1.5 && !session.graceRearmed) {
|
|
1544
|
+
session.graceRearmed = true;
|
|
1545
|
+
session.graceArmedAt = Date.now();
|
|
1546
|
+
session.graceTimer = setTimeout(() => this.graceFired(session), this.graceMs);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
this.close(session.id, "tabs-closed");
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
|
|
1553
|
+
// src/server/workspaces.ts
|
|
1554
|
+
import crypto4 from "crypto";
|
|
1555
|
+
import path10 from "path";
|
|
1556
|
+
var WorkspaceRegistry = class {
|
|
1557
|
+
byRoot = /* @__PURE__ */ new Map();
|
|
1558
|
+
byId = /* @__PURE__ */ new Map();
|
|
1559
|
+
/** Open (or reuse by realpath) a workspace for an already-realpath'd root. */
|
|
1560
|
+
open(rootReal) {
|
|
1561
|
+
const existing = this.byRoot.get(rootReal);
|
|
1562
|
+
if (existing) return existing;
|
|
1563
|
+
const ws = {
|
|
1564
|
+
id: crypto4.randomUUID(),
|
|
1565
|
+
root: rootReal,
|
|
1566
|
+
displayName: path10.basename(rootReal) || rootReal,
|
|
1567
|
+
createdAt: Date.now(),
|
|
1568
|
+
uiConns: /* @__PURE__ */ new Set()
|
|
1569
|
+
};
|
|
1570
|
+
this.byRoot.set(rootReal, ws);
|
|
1571
|
+
this.byId.set(ws.id, ws);
|
|
1572
|
+
log(`workspace ${ws.id} opened for ${rootReal}`);
|
|
1573
|
+
return ws;
|
|
1574
|
+
}
|
|
1575
|
+
get(id) {
|
|
1576
|
+
return this.byId.get(id);
|
|
1577
|
+
}
|
|
1578
|
+
/** Total live tree connections across all workspaces — gates daemon idle-exit. */
|
|
1579
|
+
activeUiConnCount() {
|
|
1580
|
+
let n = 0;
|
|
1581
|
+
for (const ws of this.byId.values()) n += ws.uiConns.size;
|
|
1582
|
+
return n;
|
|
1583
|
+
}
|
|
1584
|
+
attach(workspaceId, conn) {
|
|
1585
|
+
const ws = this.byId.get(workspaceId);
|
|
1586
|
+
if (!ws) return false;
|
|
1587
|
+
ws.uiConns.add(conn);
|
|
1588
|
+
return true;
|
|
1589
|
+
}
|
|
1590
|
+
detach(workspaceId, conn) {
|
|
1591
|
+
this.byId.get(workspaceId)?.uiConns.delete(conn);
|
|
1592
|
+
}
|
|
1593
|
+
broadcast(workspaceId, event, data) {
|
|
1594
|
+
const ws = this.byId.get(workspaceId);
|
|
1595
|
+
if (!ws) return;
|
|
1596
|
+
for (const conn of ws.uiConns) {
|
|
1597
|
+
conn.send(event, data).catch(() => this.detach(workspaceId, conn));
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Tell every workspace that contains `realPath` that something about that file
|
|
1602
|
+
* changed (mark counts, open/agent state). The relative path is injected into
|
|
1603
|
+
* the event data so the tree can target the right row. Skips workspaces with
|
|
1604
|
+
* no live tree connection.
|
|
1605
|
+
*/
|
|
1606
|
+
notifyForPath(realPath, event, dataBase = {}) {
|
|
1607
|
+
for (const ws of this.byId.values()) {
|
|
1608
|
+
if (ws.uiConns.size === 0) continue;
|
|
1609
|
+
const rel = relWithin(ws.root, realPath);
|
|
1610
|
+
if (rel === null) continue;
|
|
1611
|
+
this.broadcast(ws.id, event, { ...dataBase, rel });
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
|
|
1616
|
+
// src/server/statefile.ts
|
|
1617
|
+
import fs13 from "fs";
|
|
1618
|
+
import path11 from "path";
|
|
1619
|
+
function readServerState() {
|
|
1620
|
+
try {
|
|
1621
|
+
const raw = fs13.readFileSync(stateFilePath(), "utf8");
|
|
1622
|
+
const parsed = JSON.parse(raw);
|
|
1623
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.port === "number" && typeof parsed.pid === "number" && typeof parsed.version === "string" && typeof parsed.startedAt === "number") {
|
|
1624
|
+
return parsed;
|
|
1625
|
+
}
|
|
1626
|
+
return null;
|
|
1627
|
+
} catch {
|
|
1628
|
+
return null;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
function writeServerState(state) {
|
|
1632
|
+
const dir = ensureConfigDir();
|
|
1633
|
+
const tmp = path11.join(dir, `.server.json.${process.pid}.tmp`);
|
|
1634
|
+
fs13.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
1635
|
+
fs13.renameSync(tmp, stateFilePath());
|
|
1636
|
+
}
|
|
1637
|
+
function clearServerState(onlyIfPid) {
|
|
1638
|
+
if (onlyIfPid !== void 0) {
|
|
1639
|
+
const current = readServerState();
|
|
1640
|
+
if (current && current.pid !== onlyIfPid) return;
|
|
1641
|
+
}
|
|
1642
|
+
try {
|
|
1643
|
+
fs13.unlinkSync(stateFilePath());
|
|
1644
|
+
} catch {
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// src/server/index.ts
|
|
1649
|
+
function listenOnce(app, port) {
|
|
1650
|
+
return new Promise((resolve, reject) => {
|
|
1651
|
+
const server = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, () => resolve(server));
|
|
1652
|
+
server.on("error", reject);
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
async function listenWithScan(app, base, attempts) {
|
|
1656
|
+
let lastErr;
|
|
1657
|
+
for (let port = base; port < base + attempts; port++) {
|
|
1658
|
+
try {
|
|
1659
|
+
const server = await listenOnce(app, port);
|
|
1660
|
+
return { server, port };
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
lastErr = err;
|
|
1663
|
+
if (err.code === "EADDRINUSE") continue;
|
|
1664
|
+
throw err;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
throw lastErr instanceof Error ? lastErr : new Error("no free port found");
|
|
1668
|
+
}
|
|
1669
|
+
async function isHealthyMastermind(port) {
|
|
1670
|
+
try {
|
|
1671
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1e3) });
|
|
1672
|
+
if (!res.ok) return false;
|
|
1673
|
+
const j = await res.json();
|
|
1674
|
+
return j.ok === true && typeof j.pid === "number";
|
|
1675
|
+
} catch {
|
|
1676
|
+
return false;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
async function main() {
|
|
1680
|
+
const pinned = process.env.MASTERMIND_PINNED === "1";
|
|
1681
|
+
const base = Number(process.env.MASTERMIND_PORT || "") || DEFAULT_PORT;
|
|
1682
|
+
const startedAt = Date.now();
|
|
1683
|
+
let lastActivity = Date.now();
|
|
1684
|
+
const registry = new SessionRegistry({
|
|
1685
|
+
graceMs: Number(process.env.MASTERMIND_GRACE_MS || "") || void 0,
|
|
1686
|
+
neverOpenedMs: Number(process.env.MASTERMIND_NEVER_OPENED_MS || "") || void 0
|
|
1687
|
+
});
|
|
1688
|
+
const assist = new AssistRegistry(registry);
|
|
1689
|
+
const workspaces = new WorkspaceRegistry();
|
|
1690
|
+
registry.onChange = () => {
|
|
1691
|
+
lastActivity = Date.now();
|
|
1692
|
+
};
|
|
1693
|
+
registry.onSessionActivity = (session) => workspaces.notifyForPath(session.path, "file-badge-changed");
|
|
1694
|
+
registry.onSessionOpened = (session) => {
|
|
1695
|
+
startWatcher(session, registry);
|
|
1696
|
+
workspaces.notifyForPath(session.path, "file-badge-changed");
|
|
1697
|
+
};
|
|
1698
|
+
registry.onSessionClosed = (session) => {
|
|
1699
|
+
assist.cancelForSession(session.id);
|
|
1700
|
+
void stopWatcher(session);
|
|
1701
|
+
workspaces.notifyForPath(session.path, "file-badge-changed");
|
|
1702
|
+
if (session.isDraft) {
|
|
1703
|
+
void fsp.stat(session.path).then((st) => st.size === 0 ? fsp.unlink(session.path) : void 0).catch(() => {
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
let shuttingDown = false;
|
|
1708
|
+
let server = null;
|
|
1709
|
+
const shutdown = (reason) => {
|
|
1710
|
+
if (shuttingDown) return;
|
|
1711
|
+
shuttingDown = true;
|
|
1712
|
+
log(`shutting down (${reason})`);
|
|
1713
|
+
registry.closeAll("shutdown");
|
|
1714
|
+
clearServerState(process.pid);
|
|
1715
|
+
server?.close();
|
|
1716
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
1717
|
+
};
|
|
1718
|
+
const app = createApp({
|
|
1719
|
+
registry,
|
|
1720
|
+
workspaces,
|
|
1721
|
+
assist,
|
|
1722
|
+
version: package_default.version,
|
|
1723
|
+
startedAt,
|
|
1724
|
+
requestShutdown: shutdown,
|
|
1725
|
+
touch: () => {
|
|
1726
|
+
lastActivity = Date.now();
|
|
1727
|
+
}
|
|
1728
|
+
});
|
|
1729
|
+
const bound = await listenWithScan(app, base, pinned ? 1 : PORT_SCAN_LIMIT);
|
|
1730
|
+
server = bound.server;
|
|
1731
|
+
const existing = readServerState();
|
|
1732
|
+
if (existing && existing.pid !== process.pid && await isHealthyMastermind(existing.port)) {
|
|
1733
|
+
log(`another daemon is already healthy on port ${existing.port} \u2014 exiting`);
|
|
1734
|
+
server.close();
|
|
1735
|
+
process.exit(0);
|
|
1736
|
+
}
|
|
1737
|
+
writeServerState({ port: bound.port, pid: process.pid, version: package_default.version, startedAt });
|
|
1738
|
+
log(`mastermind v${package_default.version} listening on http://127.0.0.1:${bound.port} (pid ${process.pid})`);
|
|
1739
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1740
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1741
|
+
setInterval(() => {
|
|
1742
|
+
const idleFor = Date.now() - lastActivity;
|
|
1743
|
+
if (registry.count() === 0 && workspaces.activeUiConnCount() === 0 && idleFor > DAEMON_IDLE_EXIT_MS) {
|
|
1744
|
+
shutdown(`idle for ${Math.round(idleFor / 6e4)} min with no sessions`);
|
|
1745
|
+
}
|
|
1746
|
+
}, 6e4).unref();
|
|
1747
|
+
}
|
|
1748
|
+
main().catch((err) => {
|
|
1749
|
+
log("fatal:", err);
|
|
1750
|
+
process.exit(1);
|
|
1751
|
+
});
|
|
1752
|
+
//# sourceMappingURL=index.js.map
|